mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 13:28:43 +03:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3d14bcaf72 | ||
|
|
47d83af0b6 |
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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,9 +483,11 @@ 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"):
|
||||||
reasoning_content = (reasoning_content or "") + text
|
text = s.get("text")
|
||||||
|
if isinstance(text, str):
|
||||||
|
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 ""
|
||||||
item_id = item.get("id") or "fc_0"
|
item_id = item.get("id") or "fc_0"
|
||||||
@@ -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]}")
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
@@ -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" && (
|
||||||
|
|||||||
@@ -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,18 +408,12 @@ 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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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]"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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("");
|
||||||
|
|||||||
@@ -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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user