Compare commits

..
Author SHA1 Message Date
Xubin Ren 3d14bcaf72 refactor(webui): reuse sidebar selection highlight 2026-07-31 00:59:35 +08:00
Xubin Ren 47d83af0b6 feat(webui): add persistent Quick Chat 2026-07-31 00:53:10 +08:00
43 changed files with 641 additions and 1076 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 conversation, which helps with multi-step tasks. Supported providers can also
compact long conversations automatically. 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 Native compaction is also automatic when the provider supports it. The
threshold is derived from the active model's context window and reserved output threshold is derived from the active model's context window and reserved output
headroom; no provider configuration is required. 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. `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 ### Custom OpenAI-Compatible Endpoint
The `custom` provider fits one OpenAI-compatible endpoint that is not represented by a named provider. 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: except Exception as e:
self.logger.debug("reactions_add failed: {}", e) self.logger.debug("reactions_add failed: {}", e)
# Thread-scoped session key whenever the turn lives in a thread: either the # Thread-scoped session key whenever the user is in a real thread
# message arrived inside one (raw_thread_ts) or reply_in_thread opens a new # (raw_thread_ts is set). DM threads get their own session, separate
# thread for this channel message. DM roots have no thread_ts and keep the # from the DM root, so context doesn't bleed across thread boundaries.
# default per-chat session, so context doesn't bleed across thread boundaries. session_key = (
session_key = f"slack:{chat_id}:{thread_ts}" if thread_ts else None f"slack:{chat_id}:{thread_ts}" if thread_ts and raw_thread_ts else None
)
media_paths: list[str] = [] media_paths: list[str] = []
file_markers: list[str] = [] file_markers: list[str] = []
for file_info in _as_json_list(event.get("files")) or []: 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" 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 @pytest.mark.asyncio
async def test_slack_slash_command_skips_thread_context() -> None: async def test_slack_slash_command_skips_thread_context() -> None:
channel = SlackChannel(SlackConfig(enabled=True, allow_from=[]), MessageBus()) 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) self.logger.error("Failed to load Weixin account state", exc_info=True)
return False return False
def _save_state(self, *, force: bool = False) -> None: def _save_state(self) -> None:
state_file = self._get_state_dir() / "account.json" state_file = self._get_state_dir() / "account.json"
with suppress(Exception): 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 = { data = {
"token": self._token, "token": self._token,
"get_updates_buf": self._get_updates_buf, "get_updates_buf": self._get_updates_buf,
@@ -510,7 +489,7 @@ class WeixinChannel(BaseChannel):
self._token = token self._token = token
if base_url: if base_url:
self.config.base_url = base_url self.config.base_url = base_url
self._save_state(force=True) self._save_state()
async def connect_close_client(self) -> None: async def connect_close_client(self) -> None:
self._running = False self._running = False
@@ -634,8 +613,6 @@ class WeixinChannel(BaseChannel):
remaining = self._session_pause_remaining_s() remaining = self._session_pause_remaining_s()
if remaining > 0: if remaining > 0:
await asyncio.sleep(remaining) await asyncio.sleep(remaining)
if not self.config.token:
self._load_state()
return return
body: dict[str, Any] = { 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"} 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 @pytest.mark.asyncio
async def test_process_message_deduplicates_inbound_ids() -> None: async def test_process_message_deduplicates_inbound_ids() -> None:
channel, bus = _make_channel() 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 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 @pytest.mark.asyncio
async def test_qr_login_refreshes_expired_qr_and_then_succeeds( async def test_qr_login_refreshes_expired_qr_and_then_succeeds(
no_qr_poll_delay, no_qr_poll_delay,
+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()
+6 -21
View File
@@ -958,34 +958,22 @@ class OpenAICompatProvider(LLMProvider):
model: str | None, model: str | None,
reasoning_effort: str | None, reasoning_effort: str | None,
) -> bool: ) -> 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": if self._api_type == "chat_completions":
return False return False
spec_name = self._spec.name if self._spec is not None else None if self._spec and self._spec.name not in ("openai", "github_copilot"):
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:
return False return False
if self._api_type == "responses": if self._api_type == "responses":
# Explicit configuration means Responses is mandatory; do not # Explicit configuration means Responses is mandatory; do not
# consult the circuit breaker or fall back to Chat Completions. # consult the circuit breaker or fall back to Chat Completions.
return True 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): if not _is_direct_openai_base(self._effective_base):
return False return False
model_name = (model or self.default_model).lower()
wants = False wants = False
if model_responses: if reasoning_effort and reasoning_effort.lower() != "none":
wants = True
elif reasoning_effort and reasoning_effort.lower() != "none":
wants = True wants = True
elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")): elif any(token in model_name for token in ("gpt-5", "o1", "o3", "o4")):
wants = True wants = True
@@ -1111,13 +1099,11 @@ class OpenAICompatProvider(LLMProvider):
self._sanitize_empty_content(sanitized_state.pending_messages) 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( instructions, input_items, replayed = prepare_responses_input(
sanitized_messages, sanitized_messages,
state=sanitized_state, state=sanitized_state,
provider=self._responses_state_provider(), provider=self._responses_state_provider(),
model=model_name, model=model_name,
preserve_reasoning=preserve_reasoning,
) )
body: dict[str, Any] = { body: dict[str, Any] = {
@@ -1145,7 +1131,7 @@ class OpenAICompatProvider(LLMProvider):
if self._supports_temperature(model_name, reasoning_effort): if self._supports_temperature(model_name, reasoning_effort):
body["temperature"] = temperature 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"] body["include"] = ["reasoning.encrypted_content"]
if reasoning_effort and reasoning_effort.lower() != "none": if reasoning_effort and reasoning_effort.lower() != "none":
body["reasoning"] = {"effort": reasoning_effort} body["reasoning"] = {"effort": reasoning_effort}
@@ -1841,7 +1827,6 @@ class OpenAICompatProvider(LLMProvider):
_timed_stream(), _timed_stream(),
on_content_delta, on_content_delta,
on_tool_call_delta=on_tool_call_delta, on_tool_call_delta=on_tool_call_delta,
on_reasoning_delta=on_thinking_delta,
capture=capture, capture=capture,
) )
self._record_responses_success(model, reasoning_effort) 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 return cast(dict[str, Any], value) if isinstance(value, dict) else None
def convert_messages( def convert_messages(messages: list[dict[str, Any]]) -> tuple[str, list[dict[str, Any]]]:
messages: list[dict[str, Any]],
*,
preserve_reasoning: bool = False,
) -> tuple[str, list[dict[str, Any]]]:
"""Convert Chat Completions messages to Responses API input items. """Convert Chat Completions messages to Responses API input items.
Returns ``(system_prompt, input_items)`` where *system_prompt* is extracted Returns ``(system_prompt, input_items)`` where *system_prompt* is extracted
@@ -40,13 +36,6 @@ def convert_messages(
continue continue
if role == "assistant": 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: if isinstance(content, str) and content:
message_id = _unique_item_id(f"msg_{idx}", used_item_ids) message_id = _unique_item_id(f"msg_{idx}", used_item_ids)
input_items.append({ input_items.append({
+12 -34
View File
@@ -69,9 +69,7 @@ def _response_object(value: object) -> dict[str, Any] | None:
return object_value return object_value
dump = getattr(value, "model_dump", None) dump = getattr(value, "model_dump", None)
if callable(dump): if callable(dump):
dumped = _as_json_object(dump()) return _as_json_object(dump())
if dumped is not None:
return dumped
try: try:
return _as_json_object(vars(value)) return _as_json_object(vars(value))
except TypeError: except TypeError:
@@ -446,14 +444,6 @@ def _extract_reasoning_summary_from_output(output: object) -> str | None:
for item in _response_object_list(output): for item in _response_object_list(output):
if item.get("type") != "reasoning": if item.get("type") != "reasoning":
continue 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")): for summary in _response_object_list(item.get("summary")):
if summary.get("type") == "summary_text" and summary.get("text"): if summary.get("type") == "summary_text" and summary.get("text"):
text = summary.get("text") text = summary.get("text")
@@ -493,8 +483,10 @@ def parse_response_output(
if isinstance(refusal, str): if isinstance(refusal, str):
content_parts.append(refusal) content_parts.append(refusal)
elif item_type == "reasoning": elif item_type == "reasoning":
text = _extract_reasoning_summary_from_output([item]) for s in _response_object_list(item.get("summary")):
if text: if s.get("type") == "summary_text" and s.get("text"):
text = s.get("text")
if isinstance(text, str):
reasoning_content = (reasoning_content or "") + text reasoning_content = (reasoning_content or "") + text
elif item_type == "function_call": elif item_type == "function_call":
call_id = item.get("call_id") or "" call_id = item.get("call_id") or ""
@@ -540,7 +532,6 @@ async def consume_sdk_stream(
stream: Any, stream: Any,
on_content_delta: Callable[[str], Awaitable[None]] | None = None, on_content_delta: Callable[[str], Awaitable[None]] | None = None,
on_tool_call_delta: Callable[[dict[str, Any]], 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, capture: ResponsesStreamCapture | None = None,
) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]: ) -> tuple[str, list[ToolCallRequest], str, dict[str, int], str | None]:
"""Consume an SDK async stream from ``client.responses.create(stream=True)``.""" """Consume an SDK async stream from ``client.responses.create(stream=True)``."""
@@ -551,7 +542,6 @@ async def consume_sdk_stream(
finish_reason = "stop" finish_reason = "stop"
usage: dict[str, int] = {} usage: dict[str, int] = {}
reasoning_content: str | None = None reasoning_content: str | None = None
streamed_reasoning = False
refusal_seen = False refusal_seen = False
refusal_deltas: dict[tuple[str | None, int | None], str] = {} refusal_deltas: dict[tuple[str | None, int | None], str] = {}
emitted_refusal_text = "" emitted_refusal_text = ""
@@ -582,19 +572,6 @@ async def consume_sdk_stream(
content += delta_text content += delta_text
if on_content_delta and delta_text: if on_content_delta and delta_text:
await on_content_delta(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": elif event_type == "response.refusal.delta":
refusal_seen = True refusal_seen = True
delta_text = getattr(event, "delta", None) 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), "completion_tokens": int(getattr(usage_obj, "output_tokens", 0) or 0),
"total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0), "total_tokens": int(getattr(usage_obj, "total_tokens", 0) or 0),
} }
if not reasoning_content: for out_item in cast(list[Any], getattr(resp, "output", None) or []):
reasoning_content = _extract_reasoning_summary_from_output( if getattr(out_item, "type", None) == "reasoning":
getattr(resp, "output", None) for s in cast(list[Any], getattr(out_item, "summary", None) or []):
) if getattr(s, "type", None) == "summary_text":
if reasoning_content and on_reasoning_delta: text = getattr(s, "text", None)
await on_reasoning_delta(reasoning_content) if text:
reasoning_content = (reasoning_content or "") + text
elif event_type in {"error", "response.failed"}: elif event_type in {"error", "response.failed"}:
detail = getattr(event, "error", None) or getattr(event, "message", None) or event detail = getattr(event, "error", None) or getattr(event, "message", None) or event
raise RuntimeError(f"Response failed: {str(detail)[:500]}") raise RuntimeError(f"Response failed: {str(detail)[:500]}")
+2 -9
View File
@@ -43,7 +43,6 @@ def prepare_responses_input(
state: ProviderConversationState | None, state: ProviderConversationState | None,
provider: str, provider: str,
model: str, model: str,
preserve_reasoning: bool = False,
) -> tuple[str, list[dict[str, Any]], bool]: ) -> tuple[str, list[dict[str, Any]], bool]:
"""Build a request from exact prior items plus only newly appended messages. """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 When no compatible state exists, it is converted normally as a safe
fallback. fallback.
""" """
instructions, fallback_items = convert_messages( instructions, fallback_items = convert_messages(messages)
messages,
preserve_reasoning=preserve_reasoning,
)
if state is None or not responses_state_matches( if state is None or not responses_state_matches(
state, state,
provider=provider, provider=provider,
@@ -66,10 +62,7 @@ def prepare_responses_input(
if prior_items is None: if prior_items is None:
return instructions, fallback_items, False return instructions, fallback_items, False
_, delta_items = convert_messages( _, delta_items = convert_messages(state.pending_messages)
state.pending_messages,
preserve_reasoning=preserve_reasoning,
)
logger.debug( logger.debug(
"Replaying Responses state: prior_items={} pending_messages={}", "Replaying Responses state: prior_items={} pending_messages={}",
len(prior_items), len(prior_items),
-6
View File
@@ -111,11 +111,6 @@ class ProviderSpec:
# Substring match against the wire model name (lowercased). # Substring match against the wire model name (lowercased).
implicit_reasoning_models: tuple[str, ...] = () 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",...} + # When the model returns content as a list of {"type":"thinking",...} +
# {"type":"text",...} blocks, extract the thinking text into # {"type":"text",...} blocks, extract the thinking text into
# reasoning_content. Mistral's Magistral / reasoning-enabled responses use # reasoning_content. Mistral's Magistral / reasoning-enabled responses use
@@ -466,7 +461,6 @@ PROVIDERS: tuple[ProviderSpec, ...] = (
backend="openai_compat", backend="openai_compat",
default_api_base="https://api.deepseek.com", default_api_base="https://api.deepseek.com",
thinking_style="thinking_type", thinking_style="thinking_type",
responses_models=("deepseek-v4-flash",),
), ),
# Gemini: Google's OpenAI-compatible endpoint # Gemini: Google's OpenAI-compatible endpoint
ProviderSpec( ProviderSpec(
+1 -1
View File
@@ -51,7 +51,7 @@ dependencies = [
"filelock>=3.25.2", "filelock>=3.25.2",
"watchfiles>=1.1.1,<2.0.0", "watchfiles>=1.1.1,<2.0.0",
"packaging>=24.0", "packaging>=24.0",
"tzdata>=2025.2", "tzdata>=2025.2; sys_platform == 'win32'",
"defusedxml>=0.7.1,<1.0.0", "defusedxml>=0.7.1,<1.0.0",
"pypdf>=5.0.0,<6.0.0", "pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.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) assert not any(dep.startswith(dep_name) for dep in required)
for dependency in ( for dependency in (
"tzdata>=2025.2", "tzdata>=2025.2; sys_platform == 'win32'",
"defusedxml>=0.7.1,<1.0.0", "defusedxml>=0.7.1,<1.0.0",
"pypdf>=5.0.0,<6.0.0", "pypdf>=5.0.0,<6.0.0",
"python-docx>=1.1.0,<2.0.0", "python-docx>=1.1.0,<2.0.0",
-30
View File
@@ -1,8 +1,4 @@
import json import json
import os
import subprocess
import sys
import textwrap
import warnings import warnings
import pytest import pytest
@@ -46,32 +42,6 @@ def test_agent_timezone_rejects_unknown_iana_name() -> None:
Config.model_validate({"agents": {"defaults": {"timezone": "Not/AZone"}}}) 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: def test_provider_api_type_accepts_exact_values_only() -> None:
config = Config.model_validate({ config = Config.model_validate({
"providers": { "providers": {
-56
View File
@@ -150,22 +150,6 @@ class TestConvertMessages:
assert items[0]["content"][0]["type"] == "output_text" assert items[0]["content"][0]["type"] == "output_text"
assert items[0]["content"][0]["text"] == "I'll help" 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): def test_assistant_empty_content_skipped(self):
_, items = convert_messages([{"role": "assistant", "content": ""}]) _, items = convert_messages([{"role": "assistant", "content": ""}])
assert len(items) == 0 assert len(items) == 0
@@ -555,22 +539,6 @@ class TestParseResponseOutput:
assert result.content == "42" assert result.content == "42"
assert result.reasoning_content == "I think therefore I am." 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): def test_empty_output(self):
resp = {"output": [], "status": "completed", "usage": {}} resp = {"output": [], "status": "completed", "usage": {}}
result = parse_response_output(resp) result = parse_response_output(resp)
@@ -1665,30 +1633,6 @@ class TestConsumeSdkStream:
_, _, _, _, reasoning = await consume_sdk_stream(stream()) _, _, _, _, reasoning = await consume_sdk_stream(stream())
assert reasoning == "thinking..." 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 @pytest.mark.asyncio
async def test_error_event_raises(self): async def test_error_event_raises(self):
ev = MagicMock(type="error", error="rate_limit_exceeded") 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 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): def test_direct_openai_enables_server_compaction(provider):
provider._extra_body = {} provider._extra_body = {}
+66 -10
View File
@@ -37,6 +37,12 @@ import {
import { displayTitle } from "@/lib/chat-groups"; import { displayTitle } from "@/lib/chat-groups";
import { deriveTitle } from "@/lib/format"; import { deriveTitle } from "@/lib/format";
import { NanobotClient } from "@/lib/nanobot-client"; 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 { ClientProvider, useClient } from "@/providers/ClientProvider";
import type { import type {
BootstrapResponse, BootstrapResponse,
@@ -225,6 +231,9 @@ function readShellRoute(): ShellRoute {
if (path === "/skills") { if (path === "/skills") {
return { view: "skills", activeKey, settingsSection: "skills" }; return { view: "skills", activeKey, settingsSection: "skills" };
} }
if (path === "/quick-chat") {
return { view: "chat", activeKey: QUICK_CHAT_KEY, settingsSection: "overview" };
}
if (path.startsWith("/chat/")) { if (path.startsWith("/chat/")) {
const encoded = path.slice("/chat/".length); const encoded = path.slice("/chat/".length);
try { try {
@@ -241,6 +250,7 @@ function readShellRoute(): ShellRoute {
function shellRouteHash(route: ShellRoute): string { function shellRouteHash(route: ShellRoute): string {
if (route.view === "chat") { if (route.view === "chat") {
if (isQuickChatKey(route.activeKey)) return "#/quick-chat";
return route.activeKey return route.activeKey
? `#/chat/${encodeURIComponent(route.activeKey)}` ? `#/chat/${encodeURIComponent(route.activeKey)}`
: "#/new"; : "#/new";
@@ -947,8 +957,16 @@ function Shell({
deleteChat, deleteChat,
getSessionAutomations, getSessionAutomations,
} = useSessions(); } = 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 } = const { state: sidebarState, update: updateSidebarState } =
useSidebarState(sessions, !loading); useSidebarState(regularSessions, !loading);
const initialRouteRef = useRef<ShellRoute | null>(null); const initialRouteRef = useRef<ShellRoute | null>(null);
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute(); if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
const [activeKey, setActiveKey] = useState<string | null>( const [activeKey, setActiveKey] = useState<string | null>(
@@ -1114,8 +1132,10 @@ function Shell({
const activeSession = useMemo<ChatSummary | null>(() => { const activeSession = useMemo<ChatSummary | null>(() => {
if (!activeKey) return null; if (!activeKey) return null;
if (isQuickChatKey(activeKey)) return quickSession;
return sessions.find((s) => s.key === activeKey) ?? null; 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 runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]); const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
const activeChatId = activeSession?.chatId ?? null; const activeChatId = activeSession?.chatId ?? null;
@@ -1130,6 +1150,9 @@ function Shell({
}); });
}, [activeChatId]); }, [activeChatId]);
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => { const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
if (quickChatActive) {
return workspaces?.default_scope ?? null;
}
if (activeChatId && workspaceOverrides[activeChatId]) { if (activeChatId && workspaceOverrides[activeChatId]) {
return workspaceOverrides[activeChatId]; return workspaceOverrides[activeChatId];
} }
@@ -1141,6 +1164,7 @@ function Shell({
activeChatId, activeChatId,
activeSession?.workspaceScope, activeSession?.workspaceScope,
draftWorkspaceScope, draftWorkspaceScope,
quickChatActive,
workspaceOverrides, workspaceOverrides,
workspaces?.default_scope, workspaces?.default_scope,
]); ]);
@@ -1161,7 +1185,10 @@ function Shell({
useEffect(() => { useEffect(() => {
if (loading) return; 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) => { setUpdatedChatIds((current) => {
const next = new Set( const next = new Set(
Array.from(current).filter((chatId) => knownChatIds.has(chatId)), Array.from(current).filter((chatId) => knownChatIds.has(chatId)),
@@ -1176,6 +1203,7 @@ function Shell({
useEffect(() => { useEffect(() => {
if (loading || !activeKey) return; if (loading || !activeKey) return;
if (isQuickChatKey(activeKey)) return;
if (sessions.some((session) => session.key === activeKey)) return; if (sessions.some((session) => session.key === activeKey)) return;
const currentRoute = readShellRoute(); const currentRoute = readShellRoute();
navigate( navigate(
@@ -1417,6 +1445,18 @@ function Shell({
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
}, [navigate]); }, [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( const onNewChatInProject = useCallback(
(projectPath: string, projectName: string) => { (projectPath: string, projectName: string) => {
const base = workspaces?.default_scope ?? activeWorkspaceScope; const base = workspaces?.default_scope ?? activeWorkspaceScope;
@@ -1682,6 +1722,7 @@ function Shell({
setMobileSidebarOpen(false); setMobileSidebarOpen(false);
const nextKey = (() => { const nextKey = (() => {
if (!activeKey) return null; if (!activeKey) return null;
if (isQuickChatKey(activeKey)) return activeKey;
if (sessions.some((session) => session.key === activeKey)) return activeKey; if (sessions.some((session) => session.key === activeKey)) return activeKey;
return sessions[0]?.key ?? null; return sessions[0]?.key ?? null;
})(); })();
@@ -1773,7 +1814,10 @@ function Shell({
}); });
}, [client, t]); }, [client, t]);
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh); const onTurnEnd = useDeferredTitleRefresh(
quickChatActive ? null : activeSession,
refresh,
);
const onConfirmDelete = useCallback(async () => { const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return; if (!pendingDelete) return;
@@ -1863,7 +1907,9 @@ function Shell({
}); });
}, []); }, []);
const headerTitle = activeSession const headerTitle = quickChatActive
? t("sidebar.quickChat")
: activeSession
? sidebarState.title_overrides[activeSession.key] || ? sidebarState.title_overrides[activeSession.key] ||
activeSession.title || activeSession.title ||
deriveTitle(activeSession.preview, t("chat.newChat")) deriveTitle(activeSession.preview, t("chat.newChat"))
@@ -1900,9 +1946,12 @@ function Shell({
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]); }, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
const sidebarProps = { const sidebarProps = {
sessions, sessions: regularSessions,
activeKey, activeKey: view === "chat" ? activeKey : null,
loading, loading,
quickChatActive: view === "chat" && quickChatActive,
newChatActive: view === "chat" && activeKey === null,
onOpenQuickChat,
onNewChat, onNewChat,
onSelect: onSelectChat, onSelect: onSelectChat,
onRequestDelete, onRequestDelete,
@@ -2065,7 +2114,7 @@ function Shell({
<SessionSearchDialog <SessionSearchDialog
open open
onOpenChange={setSessionSearchOpen} onOpenChange={setSessionSearchOpen}
sessions={sessions} sessions={regularSessions}
activeKey={activeKey} activeKey={activeKey}
loading={loading} loading={loading}
titleOverrides={sidebarState.title_overrides} titleOverrides={sidebarState.title_overrides}
@@ -2090,7 +2139,7 @@ function Shell({
onToggleSidebar={toggleSidebar} onToggleSidebar={toggleSidebar}
onNewChat={onNewChat} onNewChat={onNewChat}
onCreateChat={onCreateChat} onCreateChat={onCreateChat}
onForkChat={onForkChat} onForkChat={quickChatActive ? undefined : onForkChat}
onTurnEnd={onTurnEnd} onTurnEnd={onTurnEnd}
theme={theme} theme={theme}
onToggleTheme={toggle} onToggleTheme={toggle}
@@ -2099,13 +2148,20 @@ function Shell({
hideHeader={false} hideHeader={false}
workspaceScope={activeWorkspaceScope} workspaceScope={activeWorkspaceScope}
workspaceDefaultScope={workspaces?.default_scope ?? null} workspaceDefaultScope={workspaces?.default_scope ?? null}
workspaceControls={workspaces?.controls ?? null} workspaceControls={
quickChatActive ? null : (workspaces?.controls ?? null)
}
workspaceScopeDisabled={activeChatRunning} workspaceScopeDisabled={activeChatRunning}
workspaceError={workspaceError} workspaceError={workspaceError}
onWorkspaceScopeChange={applyWorkspaceScope} onWorkspaceScopeChange={applyWorkspaceScope}
settingsSnapshot={settingsSnapshot} settingsSnapshot={settingsSnapshot}
onOpenModelSettings={onOpenModelSettings} onOpenModelSettings={onOpenModelSettings}
skills={skills} skills={skills}
allowConversationReset={!quickChatActive}
showSessionInfo={!quickChatActive}
emptyStateGreeting={
quickChatActive ? t("quickChat.greeting") : undefined
}
/> />
</div> </div>
{view !== "chat" && ( {view !== "chat" && (
+11 -84
View File
@@ -1,7 +1,6 @@
import { import {
memo, memo,
useEffect, useEffect,
useLayoutEffect,
useMemo, useMemo,
useRef, useRef,
useState, useState,
@@ -25,6 +24,10 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import {
SIDEBAR_SELECTION_ITEM_CLASS,
SidebarSelectionHighlight,
} from "@/components/SidebarSelectionHighlight";
import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format"; import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format";
import { import {
COLLAPSED_CHATS_VISIBLE_COUNT, COLLAPSED_CHATS_VISIBLE_COUNT,
@@ -106,9 +109,6 @@ export const ChatList = memo(function ChatList({
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS); const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
const listContentRef = useRef<HTMLDivElement>(null); const listContentRef = useRef<HTMLDivElement>(null);
const activeRowRef = useRef<HTMLDivElement>(null); const activeRowRef = useRef<HTMLDivElement>(null);
const activeHighlightRef = useRef<HTMLDivElement>(null);
const activeHighlightSurfaceRef = useRef<HTMLDivElement>(null);
const highlightVisibleRef = useRef(false);
const labels = useMemo<ChatGroupLabels>(() => ({ const labels = useMemo<ChatGroupLabels>(() => ({
pinned: t("chat.groups.pinned"), pinned: t("chat.groups.pinned"),
all: t("chat.groups.all"), all: t("chat.groups.all"),
@@ -163,74 +163,6 @@ export const ChatList = memo(function ChatList({
setVisibleLimit(INITIAL_VISIBLE_SESSIONS); setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
}, [showArchived, sort]); }, [showArchived, sort]);
useLayoutEffect(() => {
let resetTransitionFrame: number | null = null;
const updateHighlight = () => {
const content = listContentRef.current;
const row = activeRowRef.current;
const highlight = activeHighlightRef.current;
const surface = activeHighlightSurfaceRef.current;
if (!highlight || !surface) return;
if (!content || !row) {
surface.style.opacity = "0";
surface.style.transform = "scale(0.97)";
highlightVisibleRef.current = false;
return;
}
const shouldFloatIn = !highlightVisibleRef.current;
if (shouldFloatIn) {
highlight.style.transitionProperty = "none";
}
const contentRect = content.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
highlight.style.width = `${rowRect.width}px`;
highlight.style.height = `${rowRect.height}px`;
highlight.style.transform = `translate3d(${rowRect.left - contentRect.left}px, ${
rowRect.top - contentRect.top
}px, 0)`;
if (shouldFloatIn) {
void highlight.offsetWidth;
}
surface.style.opacity = "1";
surface.style.transform = "scale(1)";
highlightVisibleRef.current = true;
if (shouldFloatIn) {
resetTransitionFrame = window.requestAnimationFrame(() => {
highlight.style.removeProperty("transition-property");
resetTransitionFrame = null;
});
}
};
updateHighlight();
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(updateHighlight);
if (resizeObserver) {
if (listContentRef.current) resizeObserver.observe(listContentRef.current);
if (activeRowRef.current) resizeObserver.observe(activeRowRef.current);
}
window.addEventListener("resize", updateHighlight);
return () => {
if (resetTransitionFrame !== null) {
window.cancelAnimationFrame(resetTransitionFrame);
}
activeHighlightRef.current?.style.removeProperty("transition-property");
resizeObserver?.disconnect();
window.removeEventListener("resize", updateHighlight);
};
}, [activeKey, density, limitedGroups, showPreviews, showTimestamps]);
if (loading && sessions.length === 0) { if (loading && sessions.length === 0) {
return ( return (
<div className="px-3 py-6 text-[12px] text-muted-foreground"> <div className="px-3 py-6 text-[12px] text-muted-foreground">
@@ -333,7 +265,8 @@ export const ChatList = memo(function ChatList({
ref={active ? activeRowRef : undefined} ref={active ? activeRowRef : undefined}
data-chat-row={s.key} data-chat-row={s.key}
className={cn( className={cn(
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors", "group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px]",
SIDEBAR_SELECTION_ITEM_CLASS,
compact ? "min-h-7" : "min-h-8", compact ? "min-h-7" : "min-h-8",
active active
? "text-sidebar-accent-foreground" ? "text-sidebar-accent-foreground"
@@ -475,20 +408,14 @@ export const ChatList = memo(function ChatList({
</button> </button>
</div> </div>
) : null} ) : null}
<div <SidebarSelectionHighlight
ref={activeHighlightRef} containerRef={listContentRef}
data-testid="active-chat-highlight" targetRef={activeRowRef}
aria-hidden="true" activeId={activeKey}
className="pointer-events-none absolute left-0 top-0 z-0 !mt-0 transition-[transform,width,height] duration-300 ease-out will-change-transform motion-reduce:transition-none" scope="sessions"
>
<div
ref={activeHighlightSurfaceRef}
data-testid="active-chat-highlight-surface"
className="h-full w-full scale-[0.97] rounded-xl bg-sidebar-foreground/[0.055] opacity-0 transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none dark:bg-white/[0.07]"
/> />
</div> </div>
</div> </div>
</div>
); );
}); });
+54 -7
View File
@@ -1,8 +1,14 @@
import { useState, type ReactNode } from "react"; import {
type ReactNode,
type RefObject,
useRef,
useState,
} from "react";
import { import {
Archive, Archive,
Brain, Brain,
CalendarClock, CalendarClock,
MessageCircle,
Menu, Menu,
Search, Search,
Settings, Settings,
@@ -13,6 +19,10 @@ import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList"; import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge"; import { ConnectionBadge } from "@/components/ConnectionBadge";
import {
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
SidebarSelectionHighlight,
} from "@/components/SidebarSelectionHighlight";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import type { import type {
ChatSummary, ChatSummary,
@@ -24,6 +34,9 @@ interface SidebarProps {
sessions: ChatSummary[]; sessions: ChatSummary[];
activeKey: string | null; activeKey: string | null;
loading: boolean; loading: boolean;
quickChatActive: boolean;
newChatActive: boolean;
onOpenQuickChat: () => void;
onNewChat: () => void; onNewChat: () => void;
onSelect: (key: string) => void; onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void; onRequestDelete: (key: string, label: string) => void;
@@ -82,6 +95,15 @@ export function Sidebar(props: SidebarProps) {
const collapsed = Boolean(props.collapsed); const collapsed = Boolean(props.collapsed);
const toggleLabel = t("thread.header.toggleSidebar"); const toggleLabel = t("thread.header.toggleSidebar");
const newChatShortcut = newChatShortcutLabel(); const newChatShortcut = newChatShortcutLabel();
const actionListRef = useRef<HTMLDivElement>(null);
const activeActionRef = useRef<HTMLButtonElement>(null);
const activeActionId = props.quickChatActive
? "quick-chat"
: props.newChatActive
? "new-chat"
: props.activeUtility
? `utility:${props.activeUtility}`
: null;
return ( return (
<nav <nav
@@ -134,15 +156,26 @@ export function Sidebar(props: SidebarProps) {
</div> </div>
<div <div
ref={actionListRef}
className={cn( className={cn(
"space-y-1.5 px-2 pb-2", "relative space-y-1.5 px-2 pb-2",
collapsed && "flex w-14 flex-col items-center px-0", collapsed && "flex w-14 flex-col items-center px-0",
)} )}
> >
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.quickChat")}
onClick={props.onOpenQuickChat}
active={props.quickChatActive}
selectionRef={activeActionRef}
icon={<MessageCircle className="h-4 w-4" />}
/>
<SidebarActionButton <SidebarActionButton
collapsed={collapsed} collapsed={collapsed}
label={t("sidebar.newChat")} label={t("sidebar.newChat")}
onClick={props.onNewChat} onClick={props.onNewChat}
active={props.newChatActive}
selectionRef={activeActionRef}
icon={<SquarePen className="h-4 w-4" />} icon={<SquarePen className="h-4 w-4" />}
shortcut={newChatShortcut} shortcut={newChatShortcut}
ariaKeyShortcuts="Meta+Shift+O Control+Shift+O" ariaKeyShortcuts="Meta+Shift+O Control+Shift+O"
@@ -159,6 +192,7 @@ export function Sidebar(props: SidebarProps) {
onClick={props.onOpenApps} onClick={props.onOpenApps}
onIntent={props.onSettingsIntent} onIntent={props.onSettingsIntent}
active={props.activeUtility === "apps"} active={props.activeUtility === "apps"}
selectionRef={activeActionRef}
icon={<Blocks className="h-4 w-4" />} icon={<Blocks className="h-4 w-4" />}
/> />
<SidebarActionButton <SidebarActionButton
@@ -167,6 +201,7 @@ export function Sidebar(props: SidebarProps) {
onClick={props.onOpenSkills} onClick={props.onOpenSkills}
onIntent={props.onSettingsIntent} onIntent={props.onSettingsIntent}
active={props.activeUtility === "skills"} active={props.activeUtility === "skills"}
selectionRef={activeActionRef}
icon={<Brain className="h-4 w-4" />} icon={<Brain className="h-4 w-4" />}
/> />
<SidebarActionButton <SidebarActionButton
@@ -175,6 +210,7 @@ export function Sidebar(props: SidebarProps) {
onClick={props.onOpenAutomations} onClick={props.onOpenAutomations}
onIntent={props.onSettingsIntent} onIntent={props.onSettingsIntent}
active={props.activeUtility === "automations"} active={props.activeUtility === "automations"}
selectionRef={activeActionRef}
icon={<CalendarClock className="h-4 w-4" />} icon={<CalendarClock className="h-4 w-4" />}
/> />
{props.archivedCount ? ( {props.archivedCount ? (
@@ -185,6 +221,12 @@ export function Sidebar(props: SidebarProps) {
icon={<Archive className="h-4 w-4" />} icon={<Archive className="h-4 w-4" />}
/> />
) : null} ) : null}
<SidebarSelectionHighlight
containerRef={actionListRef}
targetRef={activeActionRef}
activeId={activeActionId}
scope="actions"
/>
</div> </div>
<div <div
className={cn( className={cn(
@@ -255,6 +297,7 @@ function SidebarActionButton({
shortcut, shortcut,
ariaKeyShortcuts, ariaKeyShortcuts,
onIntent, onIntent,
selectionRef,
}: { }: {
collapsed: boolean; collapsed: boolean;
label: string; label: string;
@@ -265,13 +308,15 @@ function SidebarActionButton({
shortcut?: string; shortcut?: string;
ariaKeyShortcuts?: string; ariaKeyShortcuts?: string;
onIntent?: () => void; onIntent?: () => void;
selectionRef?: RefObject<HTMLButtonElement>;
}) { }) {
const title = shortcut ? `${label} (${shortcut})` : collapsed ? label : undefined; const title = shortcut ? `${label} (${shortcut})` : collapsed ? label : undefined;
return ( return (
<Button <Button
ref={active ? selectionRef : undefined}
type="button" type="button"
variant="ghost" variant={null}
aria-label={label} aria-label={label}
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
aria-keyshortcuts={ariaKeyShortcuts} aria-keyshortcuts={ariaKeyShortcuts}
@@ -280,12 +325,14 @@ function SidebarActionButton({
onFocus={onIntent} onFocus={onIntent}
onPointerEnter={onIntent} onPointerEnter={onIntent}
className={cn( className={cn(
"touch-target group h-8 min-w-0 gap-2 overflow-hidden rounded-full font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground", "touch-target group h-8 min-w-0 gap-2 overflow-hidden rounded-xl font-medium",
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out", SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
collapsed collapsed
? "w-9 justify-center gap-0 rounded-xl px-0" ? "w-9 justify-center gap-0 px-0"
: "w-full justify-start gap-2 px-3 text-[12.5px]", : "w-full justify-start gap-2 px-3 text-[12.5px]",
active && "bg-sidebar-accent text-sidebar-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]", active
? "text-sidebar-accent-foreground"
: "text-sidebar-foreground/85 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
className, className,
)} )}
> >
@@ -0,0 +1,90 @@
import {
type RefObject,
useLayoutEffect,
useRef,
} from "react";
interface SidebarSelectionHighlightProps {
containerRef: RefObject<HTMLElement>;
targetRef: RefObject<HTMLElement>;
activeId: string | null;
scope: string;
}
export const SIDEBAR_SELECTION_ITEM_CLASS =
"relative z-[1] transition-[color] duration-150 ease-out motion-reduce:transition-none";
export const SIDEBAR_SELECTION_ACTION_ITEM_CLASS =
"relative z-[1] transition-[width,padding,color] [transition-duration:300ms,300ms,150ms] ease-out motion-reduce:transition-none";
export function SidebarSelectionHighlight({
containerRef,
targetRef,
activeId,
scope,
}: SidebarSelectionHighlightProps) {
const highlightRef = useRef<HTMLDivElement>(null);
const positionedRef = useRef(false);
useLayoutEffect(() => {
const highlight = highlightRef.current;
const container = containerRef.current;
const target = targetRef.current;
let restoreTransitionFrame: number | null = null;
const position = () => {
if (!highlight) return;
if (!activeId || !container || !target) {
highlight.style.opacity = "0";
positionedRef.current = false;
return;
}
const firstPosition = !positionedRef.current;
if (firstPosition) highlight.style.transitionProperty = "none";
const containerRect = container.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
highlight.style.width = `${targetRect.width}px`;
highlight.style.height = `${targetRect.height}px`;
highlight.style.transform = `translate3d(${targetRect.left - containerRect.left}px, ${
targetRect.top - containerRect.top
}px, 0)`;
highlight.style.opacity = "1";
positionedRef.current = true;
if (firstPosition) {
restoreTransitionFrame = window.requestAnimationFrame(() => {
highlight.style.removeProperty("transition-property");
restoreTransitionFrame = null;
});
}
};
position();
const resizeObserver =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(position);
if (container) resizeObserver?.observe(container);
if (target) resizeObserver?.observe(target);
window.addEventListener("resize", position);
return () => {
if (restoreTransitionFrame !== null) {
window.cancelAnimationFrame(restoreTransitionFrame);
}
highlight?.style.removeProperty("transition-property");
resizeObserver?.disconnect();
window.removeEventListener("resize", position);
};
});
return (
<div
ref={highlightRef}
data-testid={`${scope}-selection-highlight`}
data-active-id={activeId ?? undefined}
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-0 !mt-0 rounded-xl bg-sidebar-foreground/[0.055] opacity-0 transition-[transform,width,height] duration-300 ease-out will-change-transform motion-reduce:transition-none dark:bg-white/[0.07]"
/>
);
}
+17 -3
View File
@@ -65,6 +65,10 @@ import { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry"; import { channelUiPresentation } from "@/channel-plugins/registry";
import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import {
SIDEBAR_SELECTION_ITEM_CLASS,
SidebarSelectionHighlight,
} from "@/components/SidebarSelectionHighlight";
import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings"; import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings";
import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap"; import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
import { ToggleButton } from "@/components/settings/ToggleButton"; import { ToggleButton } from "@/components/settings/ToggleButton";
@@ -2497,6 +2501,8 @@ function SettingsSidebar({
hostChromeInset?: boolean; hostChromeInset?: boolean;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const desktopNavRef = useRef<HTMLDivElement>(null);
const activeNavItemRef = useRef<HTMLButtonElement>(null);
const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection) const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection)
?? SETTINGS_NAV_ITEMS[0]; ?? SETTINGS_NAV_ITEMS[0];
const ActiveIcon = activeItem.icon; const ActiveIcon = activeItem.icon;
@@ -2569,19 +2575,21 @@ function SettingsSidebar({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
<div className="hidden space-y-1 lg:block"> <div ref={desktopNavRef} className="relative hidden space-y-1 lg:block">
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => { {SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
const active = key === activeSection; const active = key === activeSection;
return ( return (
<button <button
ref={active ? activeNavItemRef : undefined}
key={key} key={key}
type="button" type="button"
aria-current={active ? "page" : undefined} aria-current={active ? "page" : undefined}
onClick={() => onSelectSection(key)} onClick={() => onSelectSection(key)}
className={cn( className={cn(
"touch-target flex h-9 w-full items-center gap-2 rounded-[10px] px-2.5 text-left text-[13px] font-medium transition-colors", "touch-target flex h-9 w-full items-center gap-2 rounded-xl px-2.5 text-left text-[13px] font-medium",
SIDEBAR_SELECTION_ITEM_CLASS,
active active
? "bg-sidebar-accent text-foreground" ? "text-sidebar-accent-foreground"
: "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground", : "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
)} )}
> >
@@ -2592,6 +2600,12 @@ function SettingsSidebar({
</button> </button>
); );
})} })}
<SidebarSelectionHighlight
containerRef={desktopNavRef}
targetRef={activeNavItemRef}
activeId={activeSection}
scope="settings"
/>
</div> </div>
</nav> </nav>
+17 -5
View File
@@ -315,6 +315,9 @@ interface ThreadShellProps {
settingsSnapshot?: SettingsPayload | null; settingsSnapshot?: SettingsPayload | null;
onOpenModelSettings?: () => void; onOpenModelSettings?: () => void;
skills?: SkillSummary[]; skills?: SkillSummary[];
allowConversationReset?: boolean;
showSessionInfo?: boolean;
emptyStateGreeting?: string;
} }
function toModelBadgeLabel(modelName: string | null): string | null { function toModelBadgeLabel(modelName: string | null): string | null {
@@ -597,6 +600,9 @@ export function ThreadShell({
settingsSnapshot = null, settingsSnapshot = null,
onOpenModelSettings, onOpenModelSettings,
skills = [], skills = [],
allowConversationReset = true,
showSessionInfo = true,
emptyStateGreeting,
}: ThreadShellProps) { }: ThreadShellProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const chatId = session?.chatId ?? null; const chatId = session?.chatId ?? null;
@@ -622,6 +628,12 @@ export function ThreadShell({
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null); const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
const [booting, setBooting] = useState(false); const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]); const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const availableSlashCommands = useMemo(
() => allowConversationReset
? slashCommands
: slashCommands.filter((command) => command.command !== "/new"),
[allowConversationReset, slashCommands],
);
const cliApps = useInstalledSettingItems({ const cliApps = useInstalledSettingItems({
getToken, getToken,
eventName: CLI_APPS_CHANGED_EVENT, eventName: CLI_APPS_CHANGED_EVENT,
@@ -1374,7 +1386,7 @@ export function ThreadShell({
fallbackModelName={fallbackModelName} fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined} onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant={showHeroComposer ? "hero" : "thread"} variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands} slashCommands={availableSlashCommands}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
skills={skills} skills={skills}
@@ -1416,7 +1428,7 @@ export function ThreadShell({
fallbackModelName={fallbackModelName} fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined} onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant="hero" variant="hero"
slashCommands={slashCommands} slashCommands={availableSlashCommands}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
skills={skills} skills={skills}
@@ -1442,10 +1454,10 @@ export function ThreadShell({
</div> </div>
) : ( ) : (
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500"> <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> </div>
); );
const sessionInfoAction = historyKey ? ( const sessionInfoAction = historyKey && showSessionInfo ? (
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} /> <SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
) : undefined; ) : undefined;
const promptNavigatorAction = historyKey ? ( const promptNavigatorAction = historyKey ? (
@@ -1488,7 +1500,7 @@ export function ThreadShell({
showScrollToBottomButton={!!session} showScrollToBottomButton={!!session}
cliApps={cliApps} cliApps={cliApps}
mcpPresets={mcpPresets} mcpPresets={mcpPresets}
slashCommands={slashCommands} slashCommands={availableSlashCommands}
forkBoundaryMessageCount={forkBoundaryMessageCount} forkBoundaryMessageCount={forkBoundaryMessageCount}
hasMoreBefore={hasMoreBefore} hasMoreBefore={hasMoreBefore}
loadingOlder={loadingOlder} loadingOlder={loadingOlder}
@@ -542,7 +542,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const distance = el.scrollHeight - el.scrollTop - el.clientHeight; const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
const near = distance < NEAR_BOTTOM_PX; const near = distance < NEAR_BOTTOM_PX;
const owner = threadMotionRef.current?.observeScroll(near) ?? "automatic"; const owner = threadMotionRef.current?.observeScroll(near) ?? "automatic";
const logicallyAtBottom = owner === "automatic" || (owner === "navigation" && near); const logicallyAtBottom = owner === "automatic" || near;
setAtBottom((current) => setAtBottom((current) =>
current === logicallyAtBottom ? current : logicallyAtBottom, current === logicallyAtBottom ? current : logicallyAtBottom,
); );
@@ -557,7 +557,6 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
if (!direction) return; if (!direction) return;
threadMotionRef.current?.handleUserScrollIntent( threadMotionRef.current?.handleUserScrollIntent(
canScrollInDirection(el, direction), canScrollInDirection(el, direction),
direction === "forward",
); );
}; };
const handleWheel = (event: WheelEvent) => { const handleWheel = (event: WheelEvent) => {
@@ -573,21 +572,20 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const handlePointerDown = (event: PointerEvent) => { const handlePointerDown = (event: PointerEvent) => {
if (event.button === 0 && event.target === el) yieldCameraToUser(); if (event.button === 0 && event.target === el) yieldCameraToUser();
}; };
let lastTouchY: number | null = null; let touchStartY: number | null = null;
const handleTouchStart = (event: TouchEvent) => { const handleTouchStart = (event: TouchEvent) => {
lastTouchY = event.touches[0]?.clientY ?? null; touchStartY = event.touches[0]?.clientY ?? null;
}; };
const handleTouchMove = (event: TouchEvent) => { const handleTouchMove = (event: TouchEvent) => {
const currentY = event.touches[0]?.clientY; const currentY = event.touches[0]?.clientY;
const scrollDeltaY = const scrollDeltaY =
lastTouchY !== null && currentY !== undefined touchStartY !== null && currentY !== undefined
? lastTouchY - currentY ? touchStartY - currentY
: 0; : 0;
lastTouchY = currentY ?? null;
handleDirectionalInput(directionFromDelta(scrollDeltaY)); handleDirectionalInput(directionFromDelta(scrollDeltaY));
}; };
const handleTouchEnd = () => { const handleTouchEnd = () => {
lastTouchY = null; touchStartY = null;
}; };
const handleKeyDown = (event: KeyboardEvent) => { const handleKeyDown = (event: KeyboardEvent) => {
if ( if (
+5 -34
View File
@@ -168,9 +168,6 @@ export class ThreadMotionCoordinator {
private measurementFrameId: number | null = null; private measurementFrameId: number | null = null;
private geometryDirty = false; private geometryDirty = false;
private composerInputDuringTurn = 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) { constructor(options: ThreadMotionCoordinatorOptions) {
this.camera = options.camera; this.camera = options.camera;
@@ -201,7 +198,6 @@ export class ThreadMotionCoordinator {
if (isNewTurn) { if (isNewTurn) {
this.camera.cancel(); this.camera.cancel();
this.composerInputDuringTurn = false; this.composerInputDuringTurn = false;
this.resumeFollowArmed = false;
this.promptPositioned = turn.entry === "restored"; this.promptPositioned = turn.entry === "restored";
this.mode = this.promptPositioned && turn.hasOutput this.mode = this.promptPositioned && turn.hasOutput
? "follow-output" ? "follow-output"
@@ -253,31 +249,15 @@ export class ThreadMotionCoordinator {
this.handleUserScrollIntent(true); this.handleUserScrollIntent(true);
} }
handleUserScrollIntent(canScroll: boolean, towardLatest = false): void { handleUserScrollIntent(canScroll: boolean): void {
if (this.mode === "browsing-history" && towardLatest && !canScroll) {
this.transitionToAutoFollow(false);
return;
}
const event = canScroll ? "user-scroll" : "boundary-scroll"; const event = canScroll ? "user-scroll" : "boundary-scroll";
const transitioned = this.transition(event); if (!this.transition(event)) return;
if (this.mode === "browsing-history" && canScroll) {
this.resumeFollowArmed = towardLatest;
} else if (transitioned && this.mode === "browsing-history") {
this.resumeFollowArmed = false;
}
if (!transitioned) return;
this.camera.cancel(); this.camera.cancel();
} }
resumeAutoFollow(): void { resumeAutoFollow(): void {
this.transitionToAutoFollow(true);
}
private transitionToAutoFollow(cancelCamera: boolean): void {
if (!this.transition("resume-follow")) return; if (!this.transition("resume-follow")) return;
this.resumeFollowArmed = false; this.camera.cancel();
if (cancelCamera) this.camera.cancel();
this.onAutoFollow?.();
this.invalidateGeometry(); this.invalidateGeometry();
} }
@@ -337,19 +317,11 @@ export class ThreadMotionCoordinator {
case "navigating-history": case "navigating-history":
if (!this.camera.isFollowing()) { if (!this.camera.isFollowing()) {
this.transition("navigation-settled"); this.transition("navigation-settled");
if (nearBottom) { if (nearBottom) this.resumeAutoFollow();
this.resumeAutoFollow();
} else {
this.resumeFollowArmed = true;
}
} }
return "navigation"; return "navigation";
case "browsing-history": case "browsing-history":
if (!nearBottom) { if (!nearBottom) return "user";
this.resumeFollowArmed = true;
return "user";
}
if (!this.resumeFollowArmed) return "user";
this.resumeAutoFollow(); this.resumeAutoFollow();
return "automatic"; return "automatic";
default: default:
@@ -367,7 +339,6 @@ export class ThreadMotionCoordinator {
this.camera.cancel(); this.camera.cancel();
this.turn = { id: null, promptId: null, hasOutput: false }; this.turn = { id: null, promptId: null, hasOutput: false };
this.composerInputDuringTurn = false; this.composerInputDuringTurn = false;
this.resumeFollowArmed = false;
this.mode = "idle"; this.mode = "idle";
this.promptPositioned = false; this.promptPositioned = false;
} }
+4
View File
@@ -43,6 +43,7 @@
"sidebar": { "sidebar": {
"navigation": "Sidebar navigation", "navigation": "Sidebar navigation",
"collapse": "Collapse sidebar", "collapse": "Collapse sidebar",
"quickChat": "Quick Chat",
"newChat": "New topic", "newChat": "New topic",
"searchAria": "Search", "searchAria": "Search",
"searchPlaceholder": "Search", "searchPlaceholder": "Search",
@@ -60,6 +61,9 @@
"title": "Skills" "title": "Skills"
} }
}, },
"quickChat": {
"greeting": "What's on your mind?"
},
"settings": { "settings": {
"backToChat": "Back to chat", "backToChat": "Back to chat",
"sidebar": { "sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": { "sidebar": {
"navigation": "Navegación de la barra lateral", "navigation": "Navegación de la barra lateral",
"collapse": "Contraer barra lateral", "collapse": "Contraer barra lateral",
"quickChat": "Chat rápido",
"newChat": "Nuevo tema", "newChat": "Nuevo tema",
"searchAria": "Buscar", "searchAria": "Buscar",
"searchPlaceholder": "Buscar", "searchPlaceholder": "Buscar",
@@ -60,6 +61,9 @@
"title": "Habilidades" "title": "Habilidades"
} }
}, },
"quickChat": {
"greeting": "¿Qué tienes en mente?"
},
"settings": { "settings": {
"backToChat": "Volver al chat", "backToChat": "Volver al chat",
"sidebar": { "sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": { "sidebar": {
"navigation": "Navigation de la barre latérale", "navigation": "Navigation de la barre latérale",
"collapse": "Réduire la barre latérale", "collapse": "Réduire la barre latérale",
"quickChat": "Discussion rapide",
"newChat": "Nouveau sujet", "newChat": "Nouveau sujet",
"searchAria": "Rechercher", "searchAria": "Rechercher",
"searchPlaceholder": "Rechercher", "searchPlaceholder": "Rechercher",
@@ -60,6 +61,9 @@
"title": "Compétences" "title": "Compétences"
} }
}, },
"quickChat": {
"greeting": "De quoi avez-vous envie de parler ?"
},
"settings": { "settings": {
"backToChat": "Retour au chat", "backToChat": "Retour au chat",
"sidebar": { "sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": { "sidebar": {
"navigation": "Navigasi bilah samping", "navigation": "Navigasi bilah samping",
"collapse": "Ciutkan sidebar", "collapse": "Ciutkan sidebar",
"quickChat": "Obrolan cepat",
"newChat": "Topik baru", "newChat": "Topik baru",
"searchAria": "Cari", "searchAria": "Cari",
"searchPlaceholder": "Cari", "searchPlaceholder": "Cari",
@@ -60,6 +61,9 @@
"title": "Skill" "title": "Skill"
} }
}, },
"quickChat": {
"greeting": "Apa yang sedang kamu pikirkan?"
},
"settings": { "settings": {
"backToChat": "Kembali ke chat", "backToChat": "Kembali ke chat",
"sidebar": { "sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": { "sidebar": {
"navigation": "サイドバーのナビゲーション", "navigation": "サイドバーのナビゲーション",
"collapse": "サイドバーを閉じる", "collapse": "サイドバーを閉じる",
"quickChat": "クイックチャット",
"newChat": "新しいトピック", "newChat": "新しいトピック",
"searchAria": "検索", "searchAria": "検索",
"searchPlaceholder": "検索", "searchPlaceholder": "検索",
@@ -60,6 +61,9 @@
"title": "スキル" "title": "スキル"
} }
}, },
"quickChat": {
"greeting": "何について話しますか?"
},
"settings": { "settings": {
"backToChat": "チャットに戻る", "backToChat": "チャットに戻る",
"sidebar": { "sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": { "sidebar": {
"navigation": "사이드바 탐색", "navigation": "사이드바 탐색",
"collapse": "사이드바 접기", "collapse": "사이드바 접기",
"quickChat": "빠른 채팅",
"newChat": "새 주제", "newChat": "새 주제",
"searchAria": "검색", "searchAria": "검색",
"searchPlaceholder": "검색", "searchPlaceholder": "검색",
@@ -60,6 +61,9 @@
"title": "스킬" "title": "스킬"
} }
}, },
"quickChat": {
"greeting": "무슨 이야기를 나눠볼까요?"
},
"settings": { "settings": {
"backToChat": "채팅으로 돌아가기", "backToChat": "채팅으로 돌아가기",
"sidebar": { "sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": { "sidebar": {
"navigation": "Navegação da barra lateral", "navigation": "Navegação da barra lateral",
"collapse": "Recolher barra lateral", "collapse": "Recolher barra lateral",
"quickChat": "Chat rápido",
"newChat": "Novo tópico", "newChat": "Novo tópico",
"searchAria": "Buscar", "searchAria": "Buscar",
"searchPlaceholder": "Buscar", "searchPlaceholder": "Buscar",
@@ -60,6 +61,9 @@
"title": "Skills" "title": "Skills"
} }
}, },
"quickChat": {
"greeting": "O que você está pensando?"
},
"settings": { "settings": {
"backToChat": "Voltar para a conversa", "backToChat": "Voltar para a conversa",
"sidebar": { "sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": { "sidebar": {
"navigation": "Điều hướng thanh bên", "navigation": "Điều hướng thanh bên",
"collapse": "Thu gọn thanh bên", "collapse": "Thu gọn thanh bên",
"quickChat": "Trò chuyện nhanh",
"newChat": "Chủ đề mới", "newChat": "Chủ đề mới",
"searchAria": "Tìm kiếm", "searchAria": "Tìm kiếm",
"searchPlaceholder": "Tìm kiếm", "searchPlaceholder": "Tìm kiếm",
@@ -60,6 +61,9 @@
"title": "Kỹ năng" "title": "Kỹ năng"
} }
}, },
"quickChat": {
"greeting": "Bạn đang nghĩ gì?"
},
"settings": { "settings": {
"backToChat": "Quay lại chat", "backToChat": "Quay lại chat",
"sidebar": { "sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": { "sidebar": {
"navigation": "侧边栏导航", "navigation": "侧边栏导航",
"collapse": "收起侧边栏", "collapse": "收起侧边栏",
"quickChat": "随便聊聊",
"newChat": "新建话题", "newChat": "新建话题",
"searchAria": "搜索", "searchAria": "搜索",
"searchPlaceholder": "搜索", "searchPlaceholder": "搜索",
@@ -60,6 +61,9 @@
"title": "技能" "title": "技能"
} }
}, },
"quickChat": {
"greeting": "想聊点什么?"
},
"settings": { "settings": {
"backToChat": "返回聊天", "backToChat": "返回聊天",
"sidebar": { "sidebar": {
+4
View File
@@ -43,6 +43,7 @@
"sidebar": { "sidebar": {
"navigation": "側邊欄導覽", "navigation": "側邊欄導覽",
"collapse": "收合側邊欄", "collapse": "收合側邊欄",
"quickChat": "輕鬆聊聊",
"newChat": "新增話題", "newChat": "新增話題",
"searchAria": "搜尋", "searchAria": "搜尋",
"searchPlaceholder": "搜尋", "searchPlaceholder": "搜尋",
@@ -60,6 +61,9 @@
"title": "技能" "title": "技能"
} }
}, },
"quickChat": {
"greeting": "想聊點什麼?"
},
"settings": { "settings": {
"backToChat": "返回聊天", "backToChat": "返回聊天",
"sidebar": { "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,
};
}
+137 -2
View File
@@ -349,6 +349,107 @@ describe("App layout", () => {
).toBeTruthy(); ).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",
});
const newTopicButton = within(sidebar).getByRole("button", {
name: "New topic",
});
const actionHighlight = within(sidebar).getByTestId(
"actions-selection-highlight",
);
fireEvent.click(quickChatButton);
expect(window.location.hash).toBe("#/quick-chat");
expect(quickChatButton).toHaveAttribute("aria-current", "page");
expect(newTopicButton).not.toHaveAttribute("aria-current");
expect(quickChatButton).not.toHaveClass("bg-sidebar-accent");
expect(quickChatButton).toHaveClass("transition-[width,padding,color]");
expect(actionHighlight).toHaveAttribute("data-active-id", "quick-chat");
expect(
within(sidebar).queryByTestId("actions-selection-highlight-surface"),
).not.toBeInTheDocument();
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();
fireEvent.click(newTopicButton);
expect(window.location.hash).toBe("#/new");
expect(newTopicButton).toHaveAttribute("aria-current", "page");
expect(quickChatButton).not.toHaveAttribute("aria-current");
expect(actionHighlight).toHaveAttribute("data-active-id", "new-chat");
expect(within(sidebar).queryAllByRole("button", { current: "page" })).toHaveLength(1);
});
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 () => { it("restores the Settings route after a restart fallback hash", async () => {
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now())); localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels"); localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
@@ -2128,16 +2229,41 @@ describe("App layout", () => {
expect(window.location.hash).toBe("#/settings"); expect(window.location.hash).toBe("#/settings");
const settingsNav = screen.getByRole("navigation", { name: "Settings sections" }); const settingsNav = screen.getByRole("navigation", { name: "Settings sections" });
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" })); const overviewButton = within(settingsNav).getByRole("button", {
name: "Overview",
exact: true,
});
const modelsButton = within(settingsNav).getByRole("button", {
name: "Models",
exact: true,
});
const settingsHighlight = within(settingsNav).getByTestId(
"settings-selection-highlight",
);
expect(overviewButton).toHaveAttribute("aria-current", "page");
expect(overviewButton).not.toHaveClass("bg-sidebar-accent");
expect(overviewButton).toHaveClass("transition-[color]");
expect(settingsHighlight).toHaveAttribute("data-active-id", "overview");
fireEvent.click(modelsButton);
expect(await screen.findByText("Model presets")).toBeInTheDocument(); expect(await screen.findByText("Model presets")).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Models" })).not.toBeInTheDocument(); expect(screen.queryByRole("heading", { name: "Models" })).not.toBeInTheDocument();
expect(window.location.hash).toBe("#/settings?section=models"); expect(window.location.hash).toBe("#/settings?section=models");
expect(modelsButton).toHaveAttribute("aria-current", "page");
expect(settingsHighlight).toHaveAttribute("data-active-id", "models");
fireEvent.click(within(settingsNav).getByRole("button", { name: "Voice" })); const voiceButton = within(settingsNav).getByRole("button", {
name: "Voice",
exact: true,
});
fireEvent.click(voiceButton);
expect(await screen.findByRole("heading", { name: "Voice input" })).toBeInTheDocument(); expect(await screen.findByRole("heading", { name: "Voice input" })).toBeInTheDocument();
expect(window.location.hash).toBe("#/settings?section=voice"); expect(window.location.hash).toBe("#/settings?section=voice");
expect(voiceButton).toHaveAttribute("aria-current", "page");
expect(settingsHighlight).toHaveAttribute("data-active-id", "voice");
}); });
it("transitions between Apps and Skills without replacing the sidebar", async () => { it("transitions between Apps and Skills without replacing the sidebar", async () => {
@@ -2163,6 +2289,11 @@ describe("App layout", () => {
"aria-current", "aria-current",
"page", "page",
); );
expect(within(sidebar).getByTestId("actions-selection-highlight")).toHaveAttribute(
"data-active-id",
"utility:apps",
);
expect(within(sidebar).queryAllByRole("button", { current: "page" })).toHaveLength(1);
expect(screen.getByTestId("settings-section-transition")).toHaveAttribute( expect(screen.getByTestId("settings-section-transition")).toHaveAttribute(
"data-settings-section", "data-settings-section",
"apps", "apps",
@@ -2190,6 +2321,10 @@ describe("App layout", () => {
"aria-current", "aria-current",
"page", "page",
); );
expect(within(sidebar).getByTestId("actions-selection-highlight")).toHaveAttribute(
"data-active-id",
"utility:skills",
);
expect(document.title).toBe("Skills · nanobot"); expect(document.title).toBe("Skills · nanobot");
}); });
+10 -12
View File
@@ -220,7 +220,7 @@ describe("ChatList", () => {
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument(); expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
}); });
it("floats a borderless highlight in, then slides it between selected topics", () => { it("positions one background highlight, then slides it between selected topics", () => {
let revealFrame: FrameRequestCallback | null = null; let revealFrame: FrameRequestCallback | null = null;
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => { vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
revealFrame = callback; revealFrame = callback;
@@ -259,14 +259,15 @@ describe("ChatList", () => {
/>, />,
); );
const highlight = screen.getByTestId("active-chat-highlight"); const highlight = screen.getByTestId("sessions-selection-highlight");
const surface = screen.getByTestId("active-chat-highlight-surface"); expect(highlight).toHaveClass(
expect(surface).toHaveClass(
"bg-sidebar-foreground/[0.055]", "bg-sidebar-foreground/[0.055]",
"transition-[opacity,transform]", "transition-[transform,width,height]",
"motion-reduce:transition-none", "motion-reduce:transition-none",
); );
expect(surface).toHaveStyle("opacity: 0; transform: scale(0.97)"); expect(highlight).toHaveStyle("opacity: 0");
expect(screen.queryByTestId("sessions-selection-highlight-surface"))
.not.toBeInTheDocument();
rerender( rerender(
<ChatList <ChatList
@@ -277,18 +278,15 @@ describe("ChatList", () => {
const activeButton = screen.getByTitle("Active topic"); const activeButton = screen.getByTitle("Active topic");
expect(activeButton).toHaveAttribute("aria-current", "page"); expect(activeButton).toHaveAttribute("aria-current", "page");
expect(activeButton.parentElement).toHaveClass("transition-[color]");
expect(activeButton.parentElement).not.toHaveClass("transition-colors");
expect(activeButton.parentElement).not.toHaveClass( expect(activeButton.parentElement).not.toHaveClass(
"bg-sidebar-accent", "bg-sidebar-accent",
"shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]", "shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
); );
expect(highlight).toHaveClass(
"transition-[transform,width,height]",
"motion-reduce:transition-none",
);
expect(highlight).toHaveStyle( expect(highlight).toHaveStyle(
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); transition-property: none", "width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none",
); );
expect(surface).toHaveStyle("opacity: 1; transform: scale(1)");
revealFrame?.(0); revealFrame?.(0);
expect(highlight.style.transitionProperty).toBe(""); expect(highlight.style.transitionProperty).toBe("");
+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); expect(camera.jumpTo).toHaveBeenCalledWith(780);
coordinator.takeUserControl(); coordinator.takeUserControl();
expect(coordinator.observeScroll(true)).toBe("user");
expect(coordinator.snapshot().mode).toBe("browsing-history");
expect(coordinator.observeScroll(false)).toBe("user"); expect(coordinator.observeScroll(false)).toBe("user");
expect(coordinator.snapshot().mode).toBe("browsing-history"); expect(coordinator.snapshot().mode).toBe("browsing-history");
@@ -420,57 +417,6 @@ describe("ThreadMotionCoordinator", () => {
expect(coordinator.snapshot().mode).toBe("anchor-prompt"); 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", () => { it("preserves history browsing when an active turn is cleared", () => {
const { const {
camera, camera,
+68
View File
@@ -3369,6 +3369,74 @@ describe("ThreadShell", () => {
expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument(); 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 () => { it("does not bring back welcome cards when image mode is enabled", async () => {
const client = makeClient(); const client = makeClient();
const settings = modelSettings("deepseek-v4-pro", "deepseek"); 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 () => { it("keeps the scroll-to-bottom button above a growing composer", async () => {
const resizeObserver = stubResizeObserver(); const resizeObserver = stubResizeObserver();