mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 16:38:49 +00:00
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # webui/src/components/settings/SettingsView.tsx
This commit is contained in:
commit
848378d0db
12
README.md
12
README.md
@ -1,4 +1,7 @@
|
||||

|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="./images/readme-cover-dark.png">
|
||||
<img alt="nanobot README cover" src="./images/readme-cover-light.png">
|
||||
</picture>
|
||||
|
||||
<div align="center">
|
||||
<p>
|
||||
@ -44,6 +47,13 @@
|
||||
| Configure providers, fallback models, Langfuse, MCP, web tools, or security | [Docs](./docs/README.md) and [Configuration](./docs/configuration.md) |
|
||||
| Understand or extend the internals | [Architecture](./docs/architecture.md) and [Development](./docs/development.md) |
|
||||
|
||||
## Open Source Partners
|
||||
|
||||
<p align="center">
|
||||
<a href="https://platform.kimi.com?aff=nanobot"><picture><source media="(prefers-color-scheme: dark)" srcset="https://kimi-file.moonshot.cn/prod-chat-kimi/kfs/4/1/2026-06-05/1d8h69mt3v89kkekg24gg"><img alt="Kimi Open Source Friends" height="44" src="https://kimi-file.moonshot.cn/prod-chat-kimi/kfs/4/1/2026-06-05/1d8h69fudcmosb3pipls0"></picture></a>
|
||||
<a href="https://platform.minimaxi.com/subscribe/token-plan?code=GILTJpMTqZ&source=link"><img alt="MiniMax" height="40" src="https://mintcdn.com/minimax-zh/1UjvBcdoC6r0UeyA/logo/light.svg?fit=max&auto=format&n=1UjvBcdoC6r0UeyA&q=85&s=672d724b639b2d88d0702fae329ea4f8"></a>
|
||||
</p>
|
||||
|
||||
## 📢 News
|
||||
|
||||
- **2026-06-01** 🚀 Released **v0.2.1** — **The Workbench Release** turns the packaged WebUI into a daily agent workbench: clearer Thought/response timelines, live file-edit activity, project workspaces, model and context controls, steadier sustained goals, CLI Apps + MCP extensions, and broader provider/channel support. Please see [release notes](https://github.com/HKUDS/nanobot/releases/tag/v0.2.1) for details.
|
||||
|
||||
@ -231,7 +231,7 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
|
||||
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
|
||||
| `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) |
|
||||
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) |
|
||||
| `moonshot` | LLM (Moonshot/Kimi) | [platform.moonshot.cn](https://platform.moonshot.cn) |
|
||||
| `moonshot` | LLM (Moonshot/Kimi) | [platform.kimi.com](https://platform.kimi.com?aff=nanobot) |
|
||||
| `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
|
||||
| `xiaomi_mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) |
|
||||
| `longcat` | LLM (LongCat) | [longcat.chat](https://longcat.chat/platform/docs/zh/) |
|
||||
|
||||
BIN
images/readme-cover-dark.png
Normal file
BIN
images/readme-cover-dark.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
BIN
images/readme-cover-light.png
Normal file
BIN
images/readme-cover-light.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 166 KiB |
@ -61,6 +61,7 @@ class MemoryStore:
|
||||
self._cursor_file = self.memory_dir / ".cursor"
|
||||
self._dream_cursor_file = self.memory_dir / ".dream_cursor"
|
||||
self._corruption_logged = False # rate-limit non-int cursor warning
|
||||
self._malformed_entry_logged = False # rate-limit bad history shape warning
|
||||
self._oversize_logged = False # rate-limit oversized-entry warning
|
||||
self._append_lock = threading.Lock() # serialize cursor allocation + append
|
||||
self._git = GitStore(workspace, tracked_files=[
|
||||
@ -295,8 +296,9 @@ class MemoryStore:
|
||||
return value
|
||||
|
||||
def _iter_valid_entries(self) -> Iterator[tuple[dict[str, Any], int]]:
|
||||
"""Yield ``(entry, cursor)`` for entries with int cursors; warn once on corruption."""
|
||||
"""Yield ``(entry, cursor)`` for well-formed entries; warn once on corruption."""
|
||||
poisoned: Any = None
|
||||
malformed_cursor: int | None = None
|
||||
for entry in self._read_entries():
|
||||
raw = entry.get("cursor")
|
||||
if raw is None:
|
||||
@ -305,6 +307,9 @@ class MemoryStore:
|
||||
if cursor is None:
|
||||
poisoned = raw
|
||||
continue
|
||||
if not self._valid_history_payload(entry):
|
||||
malformed_cursor = cursor
|
||||
continue
|
||||
yield entry, cursor
|
||||
if poisoned is not None and not self._corruption_logged:
|
||||
self._corruption_logged = True
|
||||
@ -313,6 +318,22 @@ class MemoryStore:
|
||||
"Usually caused by an external writer; further occurrences suppressed.",
|
||||
poisoned,
|
||||
)
|
||||
if malformed_cursor is not None and not self._malformed_entry_logged:
|
||||
self._malformed_entry_logged = True
|
||||
logger.warning(
|
||||
"history.jsonl contains a malformed entry at cursor {}; dropping it. "
|
||||
"Usually caused by an external writer; further occurrences suppressed.",
|
||||
malformed_cursor,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _valid_history_payload(entry: dict[str, Any]) -> bool:
|
||||
if not isinstance(entry.get("timestamp"), str):
|
||||
return False
|
||||
if not isinstance(entry.get("content"), str):
|
||||
return False
|
||||
session_key = entry.get("session_key")
|
||||
return session_key is None or isinstance(session_key, str)
|
||||
|
||||
def _next_cursor(self) -> int:
|
||||
"""Read the current cursor counter and return the next value."""
|
||||
|
||||
@ -257,12 +257,17 @@ class AgentRunner:
|
||||
return []
|
||||
injected_messages: list[dict[str, Any]] = []
|
||||
for item in items:
|
||||
if isinstance(item, dict) and item.get("role") == "user" and "content" in item:
|
||||
injected_messages.append(item)
|
||||
if item is None:
|
||||
continue
|
||||
text = getattr(item, "content", str(item))
|
||||
if text.strip():
|
||||
injected_messages.append({"role": "user", "content": text})
|
||||
if isinstance(item, dict) and item.get("role") == "user" and "content" in item:
|
||||
if self._has_injection_content(item.get("content")):
|
||||
injected_messages.append(item)
|
||||
continue
|
||||
if isinstance(item, dict):
|
||||
continue
|
||||
content = getattr(item, "content") if hasattr(item, "content") else str(item)
|
||||
if self._has_injection_content(content):
|
||||
injected_messages.append({"role": "user", "content": content})
|
||||
if len(injected_messages) > _MAX_INJECTIONS_PER_TURN:
|
||||
dropped = len(injected_messages) - _MAX_INJECTIONS_PER_TURN
|
||||
logger.warning(
|
||||
@ -272,6 +277,16 @@ class AgentRunner:
|
||||
injected_messages = injected_messages[:_MAX_INJECTIONS_PER_TURN]
|
||||
return injected_messages
|
||||
|
||||
@staticmethod
|
||||
def _has_injection_content(content: Any) -> bool:
|
||||
if content is None:
|
||||
return False
|
||||
if isinstance(content, str):
|
||||
return bool(content.strip())
|
||||
if isinstance(content, list):
|
||||
return bool(content)
|
||||
return True
|
||||
|
||||
async def run(self, spec: AgentRunSpec) -> AgentRunResult:
|
||||
hook = spec.hook or AgentHook()
|
||||
messages = list(spec.initial_messages)
|
||||
|
||||
@ -16,16 +16,16 @@ from nanobot.agent.tools.context import ToolContext
|
||||
from nanobot.agent.tools.file_state import FileStates
|
||||
from nanobot.agent.tools.loader import ToolLoader
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import AgentDefaults, ToolsConfig
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.security.workspace_access import (
|
||||
WorkspaceScope,
|
||||
bind_workspace_scope,
|
||||
reset_workspace_scope,
|
||||
workspace_sandbox_status,
|
||||
)
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import AgentDefaults, ToolsConfig
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.utils.prompt_templates import render_template
|
||||
|
||||
|
||||
@ -118,6 +118,7 @@ class SubagentManager:
|
||||
return ToolsConfig(
|
||||
exec=self.tools_config.exec,
|
||||
web=self.tools_config.web,
|
||||
file=self.tools_config.file,
|
||||
restrict_to_workspace=self.restrict_to_workspace,
|
||||
)
|
||||
|
||||
|
||||
@ -10,19 +10,36 @@ from typing import Any
|
||||
from nanobot.agent.tools.base import Tool, tool_parameters
|
||||
from nanobot.agent.tools.file_state import FileStates, _hash_file, current_file_states
|
||||
from nanobot.agent.tools.path_utils import resolve_workspace_path
|
||||
from nanobot.security.workspace_access import current_tool_workspace
|
||||
from nanobot.agent.tools.schema import (
|
||||
BooleanSchema,
|
||||
IntegerSchema,
|
||||
StringSchema,
|
||||
tool_parameters_schema,
|
||||
)
|
||||
from nanobot.config_base import Base
|
||||
from nanobot.security.workspace_access import current_tool_workspace
|
||||
from nanobot.utils.helpers import build_image_content_blocks, detect_image_mime
|
||||
|
||||
|
||||
class FileToolsConfig(Base):
|
||||
"""Filesystem tools configuration."""
|
||||
|
||||
enable: bool = True # built-in file tools on by default
|
||||
|
||||
|
||||
class _FsTool(Tool):
|
||||
"""Shared base for filesystem tools — common init and path resolution."""
|
||||
|
||||
config_key = "file"
|
||||
|
||||
@classmethod
|
||||
def config_cls(cls):
|
||||
return FileToolsConfig
|
||||
|
||||
@classmethod
|
||||
def enabled(cls, ctx: Any) -> bool:
|
||||
return ctx.config.file.enable
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
workspace: Path | None = None,
|
||||
|
||||
@ -54,7 +54,14 @@ def _error_json(status: int, message: str, err_type: str = "invalid_request_erro
|
||||
)
|
||||
|
||||
|
||||
def _chat_completion_response(content: str, model: str) -> dict[str, Any]:
|
||||
def _chat_completion_response(
|
||||
content: str,
|
||||
model: str,
|
||||
usage: dict[str, int] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
prompt = (usage or {}).get("prompt_tokens", 0)
|
||||
completion = (usage or {}).get("completion_tokens", 0)
|
||||
total = (usage or {}).get("total_tokens", 0) or prompt + completion
|
||||
return {
|
||||
"id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
|
||||
"object": "chat.completion",
|
||||
@ -67,7 +74,11 @@ def _chat_completion_response(content: str, model: str) -> dict[str, Any]:
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
],
|
||||
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
||||
"usage": {
|
||||
"prompt_tokens": prompt,
|
||||
"completion_tokens": completion,
|
||||
"total_tokens": total,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@ -346,7 +357,9 @@ async def handle_chat_completions(request: web.Request) -> web.Response:
|
||||
logger.exception("Unexpected API lock error for session {}", session_key)
|
||||
return _error_json(500, "Internal server error", err_type="server_error")
|
||||
|
||||
return web.json_response(_chat_completion_response(response_text, model_name))
|
||||
return web.json_response(
|
||||
_chat_completion_response(response_text, model_name, getattr(agent_loop, "_last_usage", None))
|
||||
)
|
||||
|
||||
|
||||
async def handle_models(request: web.Request) -> web.Response:
|
||||
|
||||
@ -1282,7 +1282,8 @@ def agent(
|
||||
from nanobot.bus.events import InboundMessage
|
||||
_init_prompt_session()
|
||||
_model, _preset_tag = _model_display(config)
|
||||
console.print(f"{__logo__} Interactive mode [bold blue]({_model})[/bold blue]{_preset_tag} — type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n")
|
||||
_icon = config.agents.defaults.bot_icon or __logo__
|
||||
console.print(f"{_icon} Interactive mode [bold blue]({_model})[/bold blue]{_preset_tag} — type [bold]exit[/bold] or [bold]Ctrl+C[/bold] to quit\n")
|
||||
|
||||
if ":" in session_id:
|
||||
cli_channel, cli_chat_id = session_id.split(":", 1)
|
||||
|
||||
@ -12,6 +12,7 @@ from nanobot.cron.types import CronSchedule
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
|
||||
from nanobot.agent.tools.filesystem import FileToolsConfig
|
||||
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
||||
from nanobot.agent.tools.self import MyToolConfig
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
@ -320,6 +321,7 @@ class ToolsConfig(Base):
|
||||
|
||||
web: WebToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.web", "WebToolsConfig"))
|
||||
exec: ExecToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.shell", "ExecToolConfig"))
|
||||
file: FileToolsConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.filesystem", "FileToolsConfig"))
|
||||
cli_apps: CliAppsToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.cli_apps", "CliAppsToolConfig"))
|
||||
my: MyToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.self", "MyToolConfig"))
|
||||
image_generation: ImageGenerationToolConfig = Field(
|
||||
@ -558,6 +560,7 @@ def _resolve_tool_config_refs() -> None:
|
||||
import sys
|
||||
|
||||
from nanobot.agent.tools.cli_apps import CliAppsToolConfig
|
||||
from nanobot.agent.tools.filesystem import FileToolsConfig
|
||||
from nanobot.agent.tools.image_generation import ImageGenerationToolConfig
|
||||
from nanobot.agent.tools.self import MyToolConfig
|
||||
from nanobot.agent.tools.shell import ExecToolConfig
|
||||
@ -566,6 +569,7 @@ def _resolve_tool_config_refs() -> None:
|
||||
# Re-export into this module's namespace
|
||||
mod = sys.modules[__name__]
|
||||
mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined]
|
||||
mod.FileToolsConfig = FileToolsConfig # type: ignore[attr-defined]
|
||||
mod.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined]
|
||||
mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined]
|
||||
mod.WebSearchConfig = WebSearchConfig # type: ignore[attr-defined]
|
||||
|
||||
@ -452,9 +452,10 @@ class AnthropicProvider(LLMProvider):
|
||||
max_tokens = max(1, max_tokens)
|
||||
thinking_enabled = bool(reasoning_effort) and reasoning_effort.lower() != "none"
|
||||
|
||||
# claude-opus-4-7 deprecated the `temperature` parameter entirely — the
|
||||
# API returns 400 if it is present, on any code path.
|
||||
omit_temperature = "opus-4-7" in model_name
|
||||
# Several Anthropic models (opus-4-7, opus-4-8, fable) deprecated the
|
||||
# `temperature` parameter — the API returns 400 if it is present.
|
||||
_model_lower = model_name.lower()
|
||||
omit_temperature = any(m in _model_lower for m in ("opus-4-7", "opus-4-8", "fable"))
|
||||
|
||||
kwargs: dict[str, Any] = {
|
||||
"model": model_name,
|
||||
|
||||
@ -1370,6 +1370,8 @@ async def _parse_codex_sse_images(
|
||||
logger.error("Codex SSE failure: {}", raw[:2000])
|
||||
_collect_images_from_sse_event(event, images)
|
||||
_collect_text_from_sse_event(event, text_parts)
|
||||
if ev_type == "response.completed":
|
||||
break
|
||||
continue
|
||||
buffer.append(line)
|
||||
|
||||
|
||||
@ -87,6 +87,21 @@ class TestBuildDreamPrompt:
|
||||
assert "entry-21" in next_prompt
|
||||
assert "entry-25" in next_prompt
|
||||
|
||||
def test_skips_malformed_history_entries(self, store):
|
||||
"""Dream prompt building should tolerate externally corrupted JSONL rows."""
|
||||
store.history_file.write_text(
|
||||
'{"cursor": 1, "timestamp": "2026-04-01 10:00"}\n'
|
||||
'{"cursor": 2, "timestamp": "2026-04-01 10:01", "content": "usable memory"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = store.build_dream_prompt()
|
||||
|
||||
assert result is not None
|
||||
prompt, cursor = result
|
||||
assert cursor == 2
|
||||
assert "usable memory" in prompt
|
||||
|
||||
def test_dream_prompt_consumes_consolidator_attribute_tags(self):
|
||||
prompt = render_template(
|
||||
"agent/dream.md",
|
||||
|
||||
@ -171,6 +171,23 @@ class TestHistoryWithCursor:
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
assert [e["cursor"] for e in entries] == [2, 3]
|
||||
|
||||
def test_read_unprocessed_skips_malformed_history_payloads(self, store):
|
||||
"""Externally edited JSONL can keep an int cursor but miss required payload fields."""
|
||||
store.history_file.write_text(
|
||||
'{"cursor": 1, "timestamp": "2026-04-01 10:00", "content": "valid"}\n'
|
||||
'{"cursor": 2, "timestamp": "2026-04-01 10:01"}\n'
|
||||
'{"cursor": 3, "content": "missing timestamp"}\n'
|
||||
'{"cursor": 4, "timestamp": "2026-04-01 10:03", "content": 123}\n'
|
||||
'{"cursor": 5, "timestamp": "2026-04-01 10:04", "content": "bad session", "session_key": 42}\n'
|
||||
'{"cursor": 6, "timestamp": "2026-04-01 10:05", "content": "also valid", "session_key": "telegram:chat-1"}\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
entries = store.read_unprocessed_history(since_cursor=0)
|
||||
|
||||
assert [e["cursor"] for e in entries] == [1, 6]
|
||||
assert [e["content"] for e in entries] == ["valid", "also valid"]
|
||||
|
||||
def test_next_cursor_falls_back_when_last_entry_has_no_cursor(self, store):
|
||||
"""Regression: _next_cursor should not KeyError on entries without cursor."""
|
||||
store.history_file.write_text(
|
||||
|
||||
@ -152,6 +152,70 @@ async def test_drain_injections_skips_empty_content():
|
||||
assert result == [{"role": "user", "content": "valid"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_injections_filters_empty_dict_payloads():
|
||||
"""Pre-normalized dict injections should obey the same empty-content guard."""
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock()
|
||||
runner = AgentRunner(provider)
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
multimodal = [{"type": "image_url", "image_url": {"url": "data:image/png;base64,abc"}}]
|
||||
msgs = [
|
||||
{"role": "user", "content": ""},
|
||||
{"role": "user", "content": " "},
|
||||
{"role": "user", "content": None},
|
||||
{"role": "assistant", "content": "should not be re-injected as user"},
|
||||
None,
|
||||
{"role": "user", "content": "valid"},
|
||||
{"role": "user", "content": multimodal},
|
||||
]
|
||||
|
||||
async def cb():
|
||||
return msgs
|
||||
|
||||
spec = AgentRunSpec(
|
||||
initial_messages=[], tools=tools, model="m",
|
||||
max_iterations=1, max_tool_result_chars=1000,
|
||||
injection_callback=cb,
|
||||
)
|
||||
result = await runner._drain_injections(spec)
|
||||
assert result == [
|
||||
{"role": "user", "content": "valid"},
|
||||
{"role": "user", "content": multimodal},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_injections_skips_objects_with_none_content():
|
||||
"""Objects exposing content=None should be skipped rather than stringified."""
|
||||
from types import SimpleNamespace
|
||||
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
|
||||
provider = MagicMock()
|
||||
runner = AgentRunner(provider)
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = []
|
||||
|
||||
async def cb():
|
||||
return [
|
||||
SimpleNamespace(content=None),
|
||||
SimpleNamespace(content=""),
|
||||
SimpleNamespace(content="valid"),
|
||||
]
|
||||
|
||||
spec = AgentRunSpec(
|
||||
initial_messages=[], tools=tools, model="m",
|
||||
max_iterations=1, max_tool_result_chars=1000,
|
||||
injection_callback=cb,
|
||||
)
|
||||
result = await runner._drain_injections(spec)
|
||||
assert result == [{"role": "user", "content": "valid"}]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_drain_injections_handles_callback_exception():
|
||||
"""If the callback raises, return empty list (error is logged)."""
|
||||
@ -1155,4 +1219,3 @@ async def test_injection_cycle_cap_on_error_path():
|
||||
assert result.had_injections is True
|
||||
# Should cap: _MAX_INJECTION_CYCLES drained rounds + 1 final round that breaks
|
||||
assert call_count["n"] == _MAX_INJECTION_CYCLES + 1
|
||||
|
||||
|
||||
@ -6,7 +6,9 @@ from unittest.mock import MagicMock
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.subagent import SubagentManager
|
||||
from nanobot.agent.tools.filesystem import FileToolsConfig
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.config.schema import ToolsConfig
|
||||
from nanobot.providers.base import LLMProvider
|
||||
|
||||
|
||||
@ -51,3 +53,29 @@ async def test_subagent_build_tools_isolates_file_read_state(tmp_path):
|
||||
second_result = await second_read.execute(path="note.txt")
|
||||
assert second_result.startswith("1| hello")
|
||||
assert "File unchanged" not in second_result
|
||||
|
||||
|
||||
def test_subagent_respects_file_tool_toggle(tmp_path):
|
||||
provider = MagicMock(spec=LLMProvider)
|
||||
provider.get_default_model.return_value = "test"
|
||||
sm = SubagentManager(
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
bus=MessageBus(),
|
||||
model="test",
|
||||
max_tool_result_chars=16_000,
|
||||
tools_config=ToolsConfig(file=FileToolsConfig(enable=False)),
|
||||
)
|
||||
|
||||
tools = sm._build_tools()
|
||||
|
||||
file_tools = {
|
||||
"apply_patch",
|
||||
"edit_file",
|
||||
"find_files",
|
||||
"grep",
|
||||
"list_dir",
|
||||
"read_file",
|
||||
"write_file",
|
||||
}
|
||||
assert file_tools.isdisjoint(tools.tool_names)
|
||||
|
||||
@ -85,6 +85,41 @@ def test_opus_4_7_omits_temperature_none() -> None:
|
||||
assert "thinking" not in kw
|
||||
|
||||
|
||||
def test_opus_4_8_omits_temperature_adaptive() -> None:
|
||||
kw = _build(_make_provider("claude-opus-4-8"), "adaptive")
|
||||
assert "temperature" not in kw
|
||||
|
||||
|
||||
def test_opus_4_8_omits_temperature_enabled() -> None:
|
||||
kw = _build(_make_provider("claude-opus-4-8"), "high", max_tokens=4096)
|
||||
assert "temperature" not in kw
|
||||
|
||||
|
||||
def test_opus_4_8_omits_temperature_none() -> None:
|
||||
kw = _build(_make_provider("claude-opus-4-8"), None)
|
||||
assert "temperature" not in kw
|
||||
|
||||
|
||||
def test_fable_omits_temperature_adaptive() -> None:
|
||||
kw = _build(_make_provider("claude-fable-5"), "adaptive")
|
||||
assert "temperature" not in kw
|
||||
|
||||
|
||||
def test_fable_omits_temperature_enabled() -> None:
|
||||
kw = _build(_make_provider("claude-fable-5"), "high", max_tokens=4096)
|
||||
assert "temperature" not in kw
|
||||
|
||||
|
||||
def test_fable_omits_temperature_none() -> None:
|
||||
kw = _build(_make_provider("claude-fable-5"), None)
|
||||
assert "temperature" not in kw
|
||||
|
||||
|
||||
def test_ordinary_model_sends_temperature() -> None:
|
||||
kw = _build(_make_provider("claude-sonnet-4-6"), None)
|
||||
assert kw["temperature"] == 0.7
|
||||
|
||||
|
||||
def test_reasoning_effort_string_none_does_not_enable_thinking() -> None:
|
||||
"""reasoning_effort='none' must not enable thinking — treated same as disabled."""
|
||||
kw = _build(_make_provider(), "none")
|
||||
|
||||
@ -84,6 +84,23 @@ class FakeClient:
|
||||
return self.get_response
|
||||
|
||||
|
||||
class CodexStreamingCompleteThenErrorResponse(FakeResponse):
|
||||
async def aiter_lines(self):
|
||||
yield 'data: {"type":"response.output_item.added","item":{"id":"ig_1","type":"image_generation_call","status":"in_progress"}}'
|
||||
yield ""
|
||||
yield (
|
||||
f'data: {{"type":"response.output_item.done","item":{{"id":"ig_1",'
|
||||
f'"type":"image_generation_call","result":"{PNG_DATA_URL}","status":"completed"}}}}'
|
||||
)
|
||||
yield ""
|
||||
yield 'data: {"type":"response.completed","response":{"status":"completed"}}'
|
||||
yield ""
|
||||
raise httpx.RemoteProtocolError(
|
||||
"peer closed connection without sending complete message body "
|
||||
"(incomplete chunked read)"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_openrouter_image_generation_payload_and_response(tmp_path: Path) -> None:
|
||||
ref = tmp_path / "ref.png"
|
||||
@ -1024,6 +1041,35 @@ async def test_codex_payload_and_response(monkeypatch) -> None:
|
||||
assert body["stream"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_stops_reading_after_completed_event(monkeypatch) -> None:
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
|
||||
@dataclass
|
||||
class FakeToken:
|
||||
account_id: str = "acct-123"
|
||||
access: str = "oauth-token"
|
||||
|
||||
async def fake_to_thread(fn, *args, **kwargs):
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr("asyncio.to_thread", fake_to_thread)
|
||||
fake_oauth = SimpleNamespace(get_token=lambda: FakeToken())
|
||||
monkeypatch.setitem(sys.modules, "oauth_cli_kit", fake_oauth)
|
||||
|
||||
fake = FakeClient(CodexStreamingCompleteThenErrorResponse({}, sse_lines=[]))
|
||||
client = CodexImageGenerationClient(
|
||||
api_key=None, client=fake # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
response = await client.generate(prompt="draw a cat", model="gpt-5.4")
|
||||
|
||||
assert response.images == [PNG_DATA_URL]
|
||||
assert response.content == ""
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_codex_strips_model_prefix(monkeypatch) -> None:
|
||||
import sys
|
||||
|
||||
@ -32,6 +32,7 @@ def _make_mock_agent(response_text: str = "mock response") -> MagicMock:
|
||||
agent.process_direct = AsyncMock(return_value=response_text)
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
return agent
|
||||
|
||||
|
||||
|
||||
@ -75,6 +75,7 @@ def _make_streaming_agent(tokens: list[str]) -> MagicMock:
|
||||
return " ".join(tokens)
|
||||
|
||||
agent.process_direct = fake_process_direct
|
||||
agent._last_usage = {}
|
||||
return agent
|
||||
|
||||
|
||||
@ -133,6 +134,7 @@ async def test_stream_false_returns_json(aiohttp_client) -> None:
|
||||
agent.process_direct = AsyncMock(return_value="normal reply")
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@ -155,6 +157,7 @@ async def test_stream_default_is_false(aiohttp_client) -> None:
|
||||
agent.process_direct = AsyncMock(return_value="default reply")
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@ -209,6 +212,7 @@ async def test_stream_passes_on_stream_callbacks(aiohttp_client) -> None:
|
||||
agent.process_direct = fake_process_direct
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@ -241,6 +245,7 @@ async def test_stream_segment_end_does_not_close_sse(aiohttp_client) -> None:
|
||||
agent.process_direct = fake_process_direct
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@ -279,6 +284,7 @@ async def test_stream_uses_final_response_when_no_deltas(aiohttp_client) -> None
|
||||
agent.process_direct = fake_process_direct
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@ -320,6 +326,7 @@ async def test_stream_with_session_id(aiohttp_client) -> None:
|
||||
agent.process_direct = fake_process_direct
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@ -348,6 +355,7 @@ async def test_streaming_backend_failure_does_not_emit_success_terminator(aiohtt
|
||||
agent.process_direct = boom
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
44
tests/test_file_tool_toggle.py
Normal file
44
tests/test_file_tool_toggle.py
Normal file
@ -0,0 +1,44 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from nanobot.agent.tools.context import ToolContext
|
||||
from nanobot.agent.tools.file_state import FileStates
|
||||
from nanobot.agent.tools.filesystem import FileToolsConfig, ReadFileTool
|
||||
from nanobot.agent.tools.loader import ToolLoader
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.config.schema import Config, ToolsConfig
|
||||
|
||||
FILE_TOOL_NAMES = {
|
||||
"apply_patch",
|
||||
"edit_file",
|
||||
"find_files",
|
||||
"grep",
|
||||
"list_dir",
|
||||
"read_file",
|
||||
"write_file",
|
||||
}
|
||||
|
||||
|
||||
def test_file_tools_enabled_by_default():
|
||||
assert FileToolsConfig().enable is True
|
||||
assert Config().tools.file.enable is True
|
||||
|
||||
|
||||
def test_file_tool_gate_follows_flag():
|
||||
cfg = ToolsConfig()
|
||||
cfg.file.enable = False
|
||||
assert ReadFileTool.enabled(SimpleNamespace(config=cfg)) is False
|
||||
assert ReadFileTool.enabled(SimpleNamespace(config=ToolsConfig())) is True
|
||||
|
||||
|
||||
def test_file_tool_loader_skips_all_builtin_file_tools_when_disabled(tmp_path):
|
||||
cfg = ToolsConfig(file=FileToolsConfig(enable=False))
|
||||
ctx = ToolContext(
|
||||
config=cfg,
|
||||
workspace=str(tmp_path),
|
||||
file_state_store=FileStates(),
|
||||
)
|
||||
registry = ToolRegistry()
|
||||
|
||||
ToolLoader().load(ctx, registry)
|
||||
|
||||
assert FILE_TOOL_NAMES.isdisjoint(registry.tool_names)
|
||||
@ -33,6 +33,7 @@ def _make_mock_agent(response_text: str = "mock response") -> MagicMock:
|
||||
agent.process_direct = AsyncMock(return_value=response_text)
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
|
||||
return agent
|
||||
|
||||
|
||||
@ -78,6 +79,25 @@ def test_chat_completion_response() -> None:
|
||||
assert result["choices"][0]["message"]["content"] == "hello world"
|
||||
assert result["choices"][0]["finish_reason"] == "stop"
|
||||
assert result["id"].startswith("chatcmpl-")
|
||||
assert result["usage"]["prompt_tokens"] == 0
|
||||
assert result["usage"]["completion_tokens"] == 0
|
||||
assert result["usage"]["total_tokens"] == 0
|
||||
|
||||
|
||||
def test_chat_completion_response_with_usage() -> None:
|
||||
usage = {"prompt_tokens": 150, "completion_tokens": 42}
|
||||
result = _chat_completion_response("hello world", "test-model", usage)
|
||||
assert result["usage"]["prompt_tokens"] == 150
|
||||
assert result["usage"]["completion_tokens"] == 42
|
||||
assert result["usage"]["total_tokens"] == 192
|
||||
|
||||
|
||||
def test_chat_completion_response_preserves_provider_total_usage() -> None:
|
||||
usage = {"total_tokens": 77}
|
||||
result = _chat_completion_response("hello world", "test-model", usage)
|
||||
assert result["usage"]["prompt_tokens"] == 0
|
||||
assert result["usage"]["completion_tokens"] == 0
|
||||
assert result["usage"]["total_tokens"] == 77
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_AIOHTTP, reason="aiohttp not installed")
|
||||
@ -213,6 +233,7 @@ async def test_followup_requests_share_same_session_key(aiohttp_client) -> None:
|
||||
agent.process_direct = fake_process
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@ -250,6 +271,7 @@ async def test_fixed_session_requests_are_serialized(aiohttp_client) -> None:
|
||||
agent.process_direct = slow_process
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@ -364,6 +386,7 @@ async def test_empty_response_retry_then_success(aiohttp_client) -> None:
|
||||
agent.process_direct = sometimes_empty
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
@ -393,6 +416,7 @@ async def test_empty_response_falls_back(aiohttp_client) -> None:
|
||||
agent.process_direct = always_empty
|
||||
agent._connect_mcp = AsyncMock()
|
||||
agent.close_mcp = AsyncMock()
|
||||
agent._last_usage = {}
|
||||
|
||||
app = create_app(agent, model_name="m")
|
||||
client = await aiohttp_client(app)
|
||||
|
||||
@ -70,6 +70,7 @@ const LEGACY_COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.
|
||||
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
|
||||
const SIDEBAR_WIDTH = 272;
|
||||
const SIDEBAR_RAIL_WIDTH = 56;
|
||||
const MOBILE_SIDEBAR_WIDTH = `min(${SIDEBAR_WIDTH}px, calc(100vw - 0.75rem))`;
|
||||
const TOKEN_REFRESH_MARGIN_MS = 30_000;
|
||||
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
|
||||
type ShellView = "chat" | "settings" | "apps" | "automations" | "skills";
|
||||
@ -1531,7 +1532,7 @@ function Shell({
|
||||
showCloseButton={false}
|
||||
aria-describedby={undefined}
|
||||
className="p-0 lg:hidden"
|
||||
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
|
||||
style={{ width: MOBILE_SIDEBAR_WIDTH, maxWidth: MOBILE_SIDEBAR_WIDTH }}
|
||||
>
|
||||
<SheetTitle className="sr-only">{t("sidebar.navigation")}</SheetTitle>
|
||||
<Sidebar
|
||||
@ -1637,7 +1638,7 @@ function Shell({
|
||||
{restartToast ? (
|
||||
<div
|
||||
role="status"
|
||||
className="fixed left-1/2 top-4 z-50 -translate-x-1/2 rounded-full border border-border/70 bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg"
|
||||
className="fixed left-1/2 top-[calc(0.75rem+env(safe-area-inset-top))] z-50 max-w-[calc(100vw-1rem)] -translate-x-1/2 rounded-full border border-border/70 bg-popover px-4 py-2 text-sm font-medium text-popover-foreground shadow-lg"
|
||||
>
|
||||
{restartToast}
|
||||
</div>
|
||||
|
||||
@ -80,7 +80,7 @@ export function DeleteConfirm({
|
||||
</div>
|
||||
) : null}
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="mt-7 grid grid-cols-2 gap-3 space-x-0">
|
||||
<AlertDialogFooter className="mt-7 grid grid-cols-1 gap-3 space-x-0 sm:grid-cols-2">
|
||||
<AlertDialogCancel
|
||||
onClick={onCancel}
|
||||
className="mt-0 h-11 rounded-full border-0 bg-muted/70 px-5 text-[15px] font-semibold text-foreground shadow-none hover:bg-muted"
|
||||
|
||||
@ -107,7 +107,7 @@ export function FilePreviewPanel({
|
||||
"--file-preview-slot-width": !entered || isClosing ? "0px" : `${desktopWidth}px`,
|
||||
} as CSSProperties}
|
||||
className={cn(
|
||||
"absolute inset-y-0 right-0 z-30 w-[min(92vw,var(--file-preview-slot-width))] overflow-hidden",
|
||||
"absolute inset-y-0 right-0 z-30 w-[min(100vw,var(--file-preview-slot-width))] overflow-hidden",
|
||||
"transition-[width] duration-300 ease-out will-change-[width]",
|
||||
"md:relative md:z-auto md:w-[var(--file-preview-slot-width)] md:min-w-0 md:shrink-0",
|
||||
isClosing && "pointer-events-none",
|
||||
@ -117,7 +117,7 @@ export function FilePreviewPanel({
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-y-0 right-0 flex w-[min(92vw,var(--file-preview-width))] flex-col overflow-hidden md:w-[var(--file-preview-width)]",
|
||||
"absolute inset-y-0 right-0 flex w-[min(100vw,var(--file-preview-width))] flex-col overflow-hidden pb-[env(safe-area-inset-bottom)] md:w-[var(--file-preview-width)] md:pb-0",
|
||||
"border-l border-border/70 bg-background shadow-2xl md:shadow-none",
|
||||
"transition-[opacity,transform] duration-300 ease-out will-change-transform",
|
||||
!entered || isClosing ? "translate-x-full opacity-0" : "translate-x-0 opacity-100",
|
||||
|
||||
@ -1715,7 +1715,7 @@ export function SettingsView({
|
||||
<main className="min-w-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
|
||||
<div
|
||||
className={cn(
|
||||
"mx-auto w-full px-5 py-8 sm:px-8 lg:py-12",
|
||||
"mx-auto w-full px-4 py-6 sm:px-8 sm:py-8 lg:py-12",
|
||||
activeSection === "automations"
|
||||
? "max-w-[1220px] 2xl:max-w-[1320px]"
|
||||
: "max-w-[920px]",
|
||||
@ -1806,7 +1806,7 @@ function SettingsSidebar({
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
"flex w-full shrink-0 flex-col border-b border-border/55 bg-card/62 px-4 pb-3 shadow-[inset_0_-1px_0_rgba(255,255,255,0.55)] backdrop-blur-xl dark:bg-card/45 dark:shadow-none md:w-[17rem] md:border-b-0 md:border-r md:px-3 md:pb-4 md:shadow-[inset_-1px_0_0_rgba(255,255,255,0.55)]",
|
||||
"flex w-full shrink-0 flex-col border-b border-border/55 bg-card/62 px-3 pb-2 shadow-[inset_0_-1px_0_rgba(255,255,255,0.55)] backdrop-blur-xl dark:bg-card/45 dark:shadow-none md:w-[17rem] md:border-b-0 md:border-r md:px-3 md:pb-4 md:shadow-[inset_-1px_0_0_rgba(255,255,255,0.55)]",
|
||||
hostChromeInset ? "pt-[4.25rem] md:pt-[4.25rem]" : "pt-4 md:pt-4",
|
||||
)}
|
||||
>
|
||||
@ -2096,7 +2096,10 @@ function VersionCheckRow({ currentVersion }: { currentVersion?: string }) {
|
||||
{result?.type === "update" ? (
|
||||
<span className="inline-flex items-center gap-1.5 text-[12px] text-blue-600 dark:text-blue-300">
|
||||
<ArrowUpCircle className="h-3 w-3" aria-hidden />
|
||||
{tx("settings.about.updateAvailable", "Update available")}{result.latestVersion && ` v${result.latestVersion}`}
|
||||
{t("settings.about.updateAvailable", {
|
||||
defaultValue: "Update available v{{version}}",
|
||||
version: result.latestVersion,
|
||||
})}
|
||||
{result.pypiUrl ? (
|
||||
<a
|
||||
href={result.pypiUrl}
|
||||
@ -7064,7 +7067,7 @@ function SettingsRow({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{children ? <div className="shrink-0 sm:ml-6">{children}</div> : null}
|
||||
{children ? <div className="min-w-0 sm:ml-6 sm:shrink-0">{children}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -7080,7 +7083,7 @@ function ReadOnlyRow({
|
||||
}) {
|
||||
return (
|
||||
<SettingsRow title={title} description={description}>
|
||||
<span className="block max-w-[320px] truncate text-right text-[13px] text-muted-foreground">
|
||||
<span className="block max-w-full truncate text-left text-[13px] text-muted-foreground sm:max-w-[320px] sm:text-right">
|
||||
{value}
|
||||
</span>
|
||||
</SettingsRow>
|
||||
@ -7526,7 +7529,7 @@ function NumberInput({
|
||||
const parsed = Number(event.target.value);
|
||||
if (Number.isFinite(parsed)) onChange(parsed);
|
||||
}}
|
||||
className="h-8 w-24 rounded-full text-[13px]"
|
||||
className="h-8 w-24 max-w-full rounded-full text-[13px]"
|
||||
/>
|
||||
{suffix ? <span className="text-[12px] text-muted-foreground">{suffix}</span> : null}
|
||||
</div>
|
||||
|
||||
@ -177,7 +177,7 @@ export function TokenUsageHeatmap({
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
||||
<div className="mx-auto w-full min-w-[760px] max-w-[1054px] px-0.5">
|
||||
<div className="mx-auto w-full min-w-0 max-w-[1054px] px-0.5 sm:min-w-[760px]">
|
||||
<div className="mb-2 flex justify-end">
|
||||
<span className="text-[11px] font-normal leading-none text-muted-foreground/64">
|
||||
{tx("settings.usage.shortTitle", "Token Usage")}
|
||||
@ -195,7 +195,7 @@ export function TokenUsageHeatmap({
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
className="grid grid-flow-col grid-rows-7 gap-1.5"
|
||||
className="grid grid-flow-col grid-rows-7 gap-[3px] sm:gap-1.5"
|
||||
style={{ gridTemplateColumns: `repeat(${TOKEN_HEATMAP_COLUMNS}, minmax(0, 1fr))` }}
|
||||
aria-label={tx("settings.usage.title", "Token activity")}
|
||||
>
|
||||
@ -224,7 +224,7 @@ export function TokenUsageHeatmap({
|
||||
<span
|
||||
aria-label={ariaLabel}
|
||||
className={cn(
|
||||
"aspect-square w-full rounded-[4px] transition-transform hover:scale-110",
|
||||
"aspect-square w-full rounded-[2px] transition-transform hover:scale-110 sm:rounded-[4px]",
|
||||
tokenUsageCellClass(level, cell.future),
|
||||
)}
|
||||
/>
|
||||
|
||||
@ -419,9 +419,20 @@ function suppressNativeDragPreview(dataTransfer: DataTransfer): void {
|
||||
window.setTimeout(() => ghost.remove(), 0);
|
||||
}
|
||||
|
||||
function visualViewportBounds(): { top: number; bottom: number; height: number } {
|
||||
const viewport = window.visualViewport;
|
||||
if (!viewport) {
|
||||
return { top: 0, bottom: window.innerHeight, height: window.innerHeight };
|
||||
}
|
||||
const top = Math.max(0, viewport.offsetTop);
|
||||
const height = Math.max(0, viewport.height);
|
||||
return { top, bottom: top + height, height };
|
||||
}
|
||||
|
||||
function getVisibleBounds(el: HTMLElement): { top: number; bottom: number } {
|
||||
let top = 0;
|
||||
let bottom = window.innerHeight;
|
||||
const viewport = visualViewportBounds();
|
||||
let top = viewport.top;
|
||||
let bottom = viewport.bottom;
|
||||
let parent = el.parentElement;
|
||||
|
||||
while (parent) {
|
||||
@ -455,11 +466,12 @@ const GOAL_PANEL_MIN_HEIGHT_PX = 112;
|
||||
const GOAL_PANEL_MAX_VIEWPORT_RATIO = 0.62;
|
||||
|
||||
function measureGoalPanelMaxCssHeight(stripTopY: number): number {
|
||||
const viewport = visualViewportBounds();
|
||||
const spaceAboveStrip =
|
||||
stripTopY - GOAL_PANEL_VIEWPORT_TOP_PAD - GOAL_PANEL_GAP_ABOVE_STRIP_PX;
|
||||
stripTopY - viewport.top - GOAL_PANEL_VIEWPORT_TOP_PAD - GOAL_PANEL_GAP_ABOVE_STRIP_PX;
|
||||
return Math.min(
|
||||
Math.max(spaceAboveStrip, GOAL_PANEL_MIN_HEIGHT_PX),
|
||||
Math.floor(window.innerHeight * GOAL_PANEL_MAX_VIEWPORT_RATIO),
|
||||
Math.floor(viewport.height * GOAL_PANEL_MAX_VIEWPORT_RATIO),
|
||||
);
|
||||
}
|
||||
|
||||
@ -593,10 +605,15 @@ function RunElapsedStrip({
|
||||
if (stripWrapperRef.current && ro) {
|
||||
ro.observe(stripWrapperRef.current);
|
||||
}
|
||||
const viewport = window.visualViewport;
|
||||
viewport?.addEventListener("resize", relayout);
|
||||
viewport?.addEventListener("scroll", relayout);
|
||||
window.addEventListener("resize", relayout);
|
||||
window.addEventListener("scroll", relayout, true);
|
||||
return () => {
|
||||
ro?.disconnect();
|
||||
viewport?.removeEventListener("resize", relayout);
|
||||
viewport?.removeEventListener("scroll", relayout);
|
||||
window.removeEventListener("resize", relayout);
|
||||
window.removeEventListener("scroll", relayout, true);
|
||||
};
|
||||
@ -1111,9 +1128,14 @@ export function ThreadComposer({
|
||||
};
|
||||
|
||||
updateLayout();
|
||||
const viewport = window.visualViewport;
|
||||
viewport?.addEventListener("resize", updateLayout);
|
||||
viewport?.addEventListener("scroll", updateLayout);
|
||||
window.addEventListener("resize", updateLayout);
|
||||
document.addEventListener("scroll", updateLayout, true);
|
||||
return () => {
|
||||
viewport?.removeEventListener("resize", updateLayout);
|
||||
viewport?.removeEventListener("scroll", updateLayout);
|
||||
window.removeEventListener("resize", updateLayout);
|
||||
document.removeEventListener("scroll", updateLayout, true);
|
||||
};
|
||||
@ -1544,10 +1566,10 @@ export function ThreadComposer({
|
||||
"w-full resize-none bg-transparent",
|
||||
isHero
|
||||
? cn(
|
||||
"min-h-[78px] px-5 text-[15px] leading-6",
|
||||
"min-h-[78px] px-4 text-[15px] leading-6 sm:px-5",
|
||||
relaxedHeroInput ? "pb-2 pt-[27px]" : "pb-1.5 pt-4",
|
||||
)
|
||||
: "min-h-[50px] px-4 pb-1.5 pt-3 text-[13.5px] leading-5",
|
||||
: "min-h-[50px] px-3.5 pb-1.5 pt-3 text-[13.5px] leading-5 sm:px-4",
|
||||
);
|
||||
|
||||
return (
|
||||
@ -1699,11 +1721,13 @@ export function ThreadComposer({
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between",
|
||||
isHero ? cn("gap-1.5 px-4", showProjectPicker ? "pb-1.5" : "pb-3.5") : "gap-2 px-3 pb-2",
|
||||
"flex flex-wrap items-center justify-between gap-y-2",
|
||||
isHero
|
||||
? cn("gap-x-1.5 px-3 sm:px-4", showProjectPicker ? "pb-1.5" : "pb-3.5")
|
||||
: "gap-x-2 px-2.5 pb-2 sm:px-3",
|
||||
)}
|
||||
>
|
||||
<div className={cn("flex min-w-0 flex-1 items-center", isHero ? "gap-1.5" : "gap-2")}>
|
||||
<div className={cn("flex min-w-0 flex-1 basis-[8rem] items-center", isHero ? "gap-1.5" : "gap-2")}>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
@ -1746,7 +1770,7 @@ export function ThreadComposer({
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className={cn("flex shrink-0 items-center", isHero ? "gap-1.5" : "gap-2")}>
|
||||
<div className={cn("ml-auto flex min-w-0 shrink-0 items-center", isHero ? "gap-1.5" : "gap-2")}>
|
||||
{modelLabel && !voiceRecorder.isRecording ? (
|
||||
<ComposerModelBadge
|
||||
label={modelLabel}
|
||||
@ -1768,6 +1792,7 @@ export function ThreadComposer({
|
||||
disabled={voiceRecorder.buttonDisabled}
|
||||
aria-label={voiceButtonLabel}
|
||||
aria-keyshortcuts={VOICE_SHORTCUT_ARIA}
|
||||
title={voiceButtonTooltip}
|
||||
onPointerDown={voiceRecorder.beginPress}
|
||||
onPointerUp={voiceRecorder.endPress}
|
||||
onPointerCancel={voiceRecorder.endPress}
|
||||
@ -2071,7 +2096,9 @@ function ComposerModelBadge({
|
||||
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
|
||||
interactive && "cursor-pointer hover:bg-accent/55 hover:text-foreground",
|
||||
needsSetup && "border-amber-500/35 bg-amber-50/70 text-amber-900 dark:bg-amber-500/10 dark:text-amber-200",
|
||||
isHero ? "h-8 max-w-[12.5rem] gap-1.5 px-2 text-[11.5px]" : "h-9 max-w-[12rem] gap-2 px-2.5 text-[12px]",
|
||||
isHero
|
||||
? "h-8 max-w-[min(12.5rem,44vw)] gap-1.5 px-2 text-[11.5px]"
|
||||
: "h-9 max-w-[min(12rem,44vw)] gap-2 px-2.5 text-[12px]",
|
||||
)}
|
||||
>
|
||||
<span
|
||||
@ -2252,7 +2279,7 @@ function CliAppMentionPalette({
|
||||
onChoose(candidate);
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-10 w-full items-center gap-2.5 rounded-[13px] px-2.5 text-left transition-colors",
|
||||
"flex min-h-10 w-full items-center gap-2.5 rounded-[13px] px-2.5 py-1.5 text-left transition-colors",
|
||||
selected
|
||||
? "bg-foreground/[0.055] text-foreground"
|
||||
: "text-foreground/90 hover:bg-foreground/[0.04]",
|
||||
@ -2260,7 +2287,7 @@ function CliAppMentionPalette({
|
||||
>
|
||||
<MentionCandidateLogo candidate={candidate} selected={selected} />
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span className="shrink-0 text-[15px] font-medium tracking-normal text-foreground">
|
||||
<span className="min-w-0 truncate text-[15px] font-medium tracking-normal text-foreground">
|
||||
{displayName}
|
||||
</span>
|
||||
<span className="truncate text-[15px] font-normal tracking-normal text-muted-foreground/72">
|
||||
@ -2396,7 +2423,7 @@ function SlashCommandPalette({
|
||||
>
|
||||
<Icon className="h-4 w-4" />
|
||||
</span>
|
||||
<span className="flex min-w-0 flex-1 items-baseline gap-2">
|
||||
<span className="flex min-w-0 flex-1 flex-col gap-0.5 sm:flex-row sm:items-baseline sm:gap-2">
|
||||
<span className="min-w-0 truncate text-[13.5px] font-semibold tracking-normal text-foreground">
|
||||
{title}
|
||||
</span>
|
||||
@ -2404,7 +2431,7 @@ function SlashCommandPalette({
|
||||
{command.detail || description}
|
||||
</span>
|
||||
</span>
|
||||
<span className="ml-2 flex shrink-0 items-center gap-1.5">
|
||||
<span className="ml-2 flex max-w-[42%] shrink-0 items-center gap-1.5 sm:max-w-none">
|
||||
{command.badge || command.recent ? (
|
||||
<span className="hidden rounded-full bg-foreground/[0.055] px-2 py-1 text-[11px] font-medium text-muted-foreground sm:inline-flex">
|
||||
{command.badge ?? t("thread.composer.slash.badges.recent")}
|
||||
@ -2486,7 +2513,7 @@ function AttachmentChip({
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-col text-[11.5px] leading-4">
|
||||
<span className="truncate max-w-[14rem] font-medium" title={image.file.name}>
|
||||
<span className="max-w-[min(14rem,calc(100vw-8rem))] truncate font-medium" title={image.file.name}>
|
||||
{image.file.name}
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground">
|
||||
|
||||
@ -725,7 +725,7 @@ export function ThreadShell({
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
|
||||
<h1 className="text-balance text-[40px] font-normal leading-tight tracking-[-0.045em] text-foreground sm:text-[48px]">
|
||||
<h1 className="max-w-[30rem] text-balance text-[34px] font-normal leading-[1.08] tracking-normal text-foreground sm:text-[48px] sm:leading-tight">
|
||||
{t(heroGreetingKey)}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
@ -50,6 +50,8 @@ const NEAR_BOTTOM_PX = 48;
|
||||
const NEAR_TOP_PX = 96;
|
||||
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
|
||||
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16;
|
||||
const SOFT_KEYBOARD_MIN_INSET_PX = 80;
|
||||
const KEYBOARD_SCROLL_FRAMES = 18;
|
||||
export const INITIAL_HISTORY_WINDOW = 160;
|
||||
export const HISTORY_WINDOW_INCREMENT = 120;
|
||||
|
||||
@ -66,6 +68,35 @@ export function windowMessages(messages: UIMessage[], visibleCount: number): UIM
|
||||
return messages.slice(start);
|
||||
}
|
||||
|
||||
function isKeyboardEditableElement(element: Element | null): element is HTMLElement {
|
||||
if (!(element instanceof HTMLElement)) return false;
|
||||
if (element.isContentEditable) return true;
|
||||
if (element instanceof HTMLTextAreaElement) return true;
|
||||
if (!(element instanceof HTMLInputElement)) return false;
|
||||
return ![
|
||||
"button",
|
||||
"checkbox",
|
||||
"color",
|
||||
"file",
|
||||
"hidden",
|
||||
"image",
|
||||
"radio",
|
||||
"range",
|
||||
"reset",
|
||||
"submit",
|
||||
].includes(element.type);
|
||||
}
|
||||
|
||||
function readSoftKeyboardInsetBottom(container: HTMLElement | null): number {
|
||||
const viewport = window.visualViewport;
|
||||
if (!viewport) return 0;
|
||||
const active = document.activeElement;
|
||||
if (!isKeyboardEditableElement(active) || !container?.contains(active)) return 0;
|
||||
const layoutHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
const inset = layoutHeight - viewport.height - viewport.offsetTop;
|
||||
return inset >= SOFT_KEYBOARD_MIN_INSET_PX ? Math.ceil(inset) : 0;
|
||||
}
|
||||
|
||||
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
|
||||
messages,
|
||||
isStreaming,
|
||||
@ -99,6 +130,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const userReadingHistoryRef = useRef(false);
|
||||
const [atBottom, setAtBottom] = useState(true);
|
||||
const [composerDockHeight, setComposerDockHeight] = useState(0);
|
||||
const [keyboardInsetBottom, setKeyboardInsetBottom] = useState(0);
|
||||
const [visibleMessageCount, setVisibleMessageCount] =
|
||||
useState(INITIAL_HISTORY_WINDOW);
|
||||
const hasMessages = messages.length > 0;
|
||||
@ -116,9 +148,13 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
|
||||
? forkBoundaryMessageCount - hiddenMessageCount
|
||||
: null;
|
||||
const scrollButtonBottom = composerDockHeight > 0
|
||||
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
|
||||
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX;
|
||||
const scrollButtonBottom =
|
||||
keyboardInsetBottom
|
||||
+ (composerDockHeight > 0
|
||||
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
|
||||
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX);
|
||||
const scrollViewportStyle =
|
||||
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
|
||||
|
||||
const cancelScheduledBottomScroll = useCallback(() => {
|
||||
for (const id of scrollFrameIdsRef.current) {
|
||||
@ -131,10 +167,20 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
const el = scrollRef.current;
|
||||
const marker = bottomRef.current;
|
||||
const behavior: ScrollBehavior = smooth ? "smooth" : "auto";
|
||||
if (marker) {
|
||||
if (el) {
|
||||
const top = Math.max(0, el.scrollHeight - el.clientHeight);
|
||||
try {
|
||||
el.scrollTo?.({ top, behavior });
|
||||
if (!smooth) el.scrollTop = top;
|
||||
} catch {
|
||||
try {
|
||||
el.scrollTop = top;
|
||||
} catch {
|
||||
// Test DOMs can expose read-only scrollTop; browsers keep this writable.
|
||||
}
|
||||
}
|
||||
} else if (marker) {
|
||||
marker.scrollIntoView({ block: "end", behavior });
|
||||
} else if (el) {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior });
|
||||
}
|
||||
userReadingHistoryRef.current = false;
|
||||
setAtBottom(true);
|
||||
@ -148,14 +194,18 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
if (!force && userReadingHistoryRef.current) return;
|
||||
scrollToBottomNow(smooth);
|
||||
};
|
||||
run();
|
||||
for (let i = 1; i < frames; i += 1) {
|
||||
const scheduleNext = (remainingFrames: number) => {
|
||||
if (remainingFrames <= 0) return;
|
||||
const id = window.requestAnimationFrame(() => {
|
||||
scrollFrameIdsRef.current = scrollFrameIdsRef.current.filter((frameId) => frameId !== id);
|
||||
if (!force && userReadingHistoryRef.current) return;
|
||||
scrollToBottomNow(smooth);
|
||||
scheduleNext(remainingFrames - 1);
|
||||
});
|
||||
scrollFrameIdsRef.current.push(id);
|
||||
}
|
||||
};
|
||||
run();
|
||||
scheduleNext(frames - 1);
|
||||
},
|
||||
[cancelScheduledBottomScroll, scrollToBottomNow],
|
||||
);
|
||||
@ -216,6 +266,37 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const updateKeyboardInset = () => {
|
||||
const scrollEl = scrollRef.current;
|
||||
const next = readSoftKeyboardInsetBottom(scrollEl);
|
||||
const active = document.activeElement;
|
||||
const composerFocused =
|
||||
hasMessages && isKeyboardEditableElement(active) && Boolean(scrollEl?.contains(active));
|
||||
setKeyboardInsetBottom((current) =>
|
||||
Math.abs(current - next) < 1 ? current : next,
|
||||
);
|
||||
if (composerFocused) {
|
||||
userReadingHistoryRef.current = false;
|
||||
scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true });
|
||||
}
|
||||
};
|
||||
updateKeyboardInset();
|
||||
const viewport = window.visualViewport;
|
||||
viewport?.addEventListener("resize", updateKeyboardInset);
|
||||
viewport?.addEventListener("scroll", updateKeyboardInset);
|
||||
window.addEventListener("resize", updateKeyboardInset);
|
||||
document.addEventListener("focusin", updateKeyboardInset);
|
||||
document.addEventListener("focusout", updateKeyboardInset);
|
||||
return () => {
|
||||
viewport?.removeEventListener("resize", updateKeyboardInset);
|
||||
viewport?.removeEventListener("scroll", updateKeyboardInset);
|
||||
window.removeEventListener("resize", updateKeyboardInset);
|
||||
document.removeEventListener("focusin", updateKeyboardInset);
|
||||
document.removeEventListener("focusout", updateKeyboardInset);
|
||||
};
|
||||
}, [hasMessages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!atBottom) return;
|
||||
// Instant jump: CSS scroll-smooth + behavior "auto" still animates in some
|
||||
@ -223,6 +304,31 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
scrollToBottom(false);
|
||||
}, [messages, atBottom, scrollToBottom]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (keyboardInsetBottom > 0) {
|
||||
userReadingHistoryRef.current = false;
|
||||
scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true });
|
||||
return;
|
||||
}
|
||||
if (userReadingHistoryRef.current) return;
|
||||
scrollToBottom(false, 4);
|
||||
}, [keyboardInsetBottom, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const scrollEl = scrollRef.current;
|
||||
if (!scrollEl) return;
|
||||
|
||||
const onComposerFocus = () => {
|
||||
const active = document.activeElement;
|
||||
if (!hasMessages || !isKeyboardEditableElement(active) || !scrollEl.contains(active)) return;
|
||||
userReadingHistoryRef.current = false;
|
||||
scrollToBottom(false, KEYBOARD_SCROLL_FRAMES, { force: true });
|
||||
};
|
||||
|
||||
document.addEventListener("focusin", onComposerFocus);
|
||||
return () => document.removeEventListener("focusin", onComposerFocus);
|
||||
}, [hasMessages, scrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollToBottomSignal <= 0) return;
|
||||
userReadingHistoryRef.current = false;
|
||||
@ -332,10 +438,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
|
||||
"[&::-webkit-scrollbar-track]:bg-transparent",
|
||||
)}
|
||||
style={scrollViewportStyle}
|
||||
>
|
||||
{hasMessages ? (
|
||||
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col">
|
||||
<div className="flex-1 px-4 pb-20 pt-4">
|
||||
<div
|
||||
data-testid="thread-message-region"
|
||||
className="flex min-h-0 flex-1 flex-col justify-end px-3 pb-4 pt-4 sm:px-4"
|
||||
>
|
||||
<div className="mx-auto w-full max-w-[49.5rem]">
|
||||
<ThreadMessages
|
||||
messages={visibleMessages}
|
||||
@ -355,16 +465,16 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
data-testid="thread-composer-dock"
|
||||
className="sticky bottom-0 z-10 mt-auto bg-background"
|
||||
>
|
||||
<div className="px-4 pb-3">
|
||||
<div className="px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] sm:px-4">
|
||||
{composer}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[72rem] flex-col px-4">
|
||||
<div className="flex w-full flex-1 items-center justify-center py-10 sm:py-12">
|
||||
<div className="relative w-full max-w-[58rem]">
|
||||
<div className="absolute inset-x-0 bottom-[calc(100%+1.5rem)] flex justify-center">
|
||||
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[72rem] flex-col px-3 sm:px-4">
|
||||
<div className="flex w-full flex-1 items-center justify-center py-6 sm:py-12">
|
||||
<div className="relative flex w-full max-w-[58rem] flex-col items-center gap-5 sm:block">
|
||||
<div className="flex justify-center sm:absolute sm:inset-x-0 sm:bottom-[calc(100%+1.5rem)]">
|
||||
{emptyState}
|
||||
</div>
|
||||
<div className="w-full">{composer}</div>
|
||||
|
||||
@ -106,7 +106,7 @@ export function WorkspaceProjectPicker({
|
||||
|
||||
if (nativeProjectPicker) {
|
||||
return (
|
||||
<div className="flex items-center rounded-b-[28px] border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
|
||||
<div className="flex min-w-0 items-center rounded-b-[28px] border-t border-border/25 bg-muted/60 px-3 py-1.5 dark:bg-white/[0.055] sm:px-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || pickingFolder}
|
||||
@ -114,7 +114,7 @@ export function WorkspaceProjectPicker({
|
||||
title={currentProjectScope?.project_path}
|
||||
onClick={() => void pickNativeFolder()}
|
||||
className={cn(
|
||||
"inline-flex h-7 max-w-[18rem] items-center gap-2 rounded-full px-2.5",
|
||||
"inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
||||
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
|
||||
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
||||
currentProjectScope && "text-foreground/82",
|
||||
@ -124,7 +124,7 @@ export function WorkspaceProjectPicker({
|
||||
<span className="truncate">{projectLabel}</span>
|
||||
</button>
|
||||
{pathError || error ? (
|
||||
<span role="alert" className="ml-2 truncate text-[11.5px] font-medium text-destructive">
|
||||
<span role="alert" className="ml-2 min-w-0 truncate text-[11.5px] font-medium text-destructive">
|
||||
{pathError ?? error}
|
||||
</span>
|
||||
) : null}
|
||||
@ -133,7 +133,7 @@ export function WorkspaceProjectPicker({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center rounded-b-[28px] border-t border-border/25 bg-muted/60 px-4 py-1.5 dark:bg-white/[0.055]">
|
||||
<div className="flex min-w-0 items-center rounded-b-[28px] border-t border-border/25 bg-muted/60 px-3 py-1.5 dark:bg-white/[0.055] sm:px-4">
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
@ -141,7 +141,7 @@ export function WorkspaceProjectPicker({
|
||||
disabled={disabled}
|
||||
aria-label={t("thread.composer.workspace.projectAria")}
|
||||
className={cn(
|
||||
"inline-flex h-7 max-w-[18rem] items-center gap-2 rounded-full px-2.5",
|
||||
"inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
||||
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
|
||||
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
||||
currentProjectScope && "text-foreground/82",
|
||||
@ -254,7 +254,7 @@ export function WorkspaceAccessMenu({
|
||||
variant="ghost"
|
||||
aria-label={t("thread.composer.workspace.accessAria")}
|
||||
className={cn(
|
||||
"max-w-[12.5rem] rounded-[10px] border border-transparent font-semibold shadow-none",
|
||||
"max-w-[min(12.5rem,42vw)] rounded-[10px] border border-transparent font-semibold shadow-none",
|
||||
isHero ? "h-8 px-2.5 text-[12px]" : "h-9 px-3 text-[12.5px]",
|
||||
isFull
|
||||
? "bg-transparent text-orange-600 hover:bg-orange-500/8 dark:text-orange-300 dark:hover:bg-orange-400/10"
|
||||
|
||||
@ -88,6 +88,7 @@
|
||||
"interface": "Interface",
|
||||
"ai": "AI",
|
||||
"system": "System",
|
||||
"about": "About",
|
||||
"status": "Status",
|
||||
"localPreferences": "Local preferences",
|
||||
"presets": "Presets",
|
||||
@ -253,6 +254,13 @@
|
||||
"unavailable": "Unavailable",
|
||||
"noDescription": "No description available."
|
||||
},
|
||||
"about": {
|
||||
"version": "Version",
|
||||
"checking": "Checking...",
|
||||
"checkForUpdates": "Check for updates",
|
||||
"upToDate": "You're up to date",
|
||||
"updateAvailable": "Update available v{{version}}"
|
||||
},
|
||||
"mcp": {
|
||||
"allCategories": "All categories",
|
||||
"summary": "{{installed}} of {{total}} presets enabled",
|
||||
|
||||
@ -88,6 +88,7 @@
|
||||
"interface": "Interfaz",
|
||||
"ai": "AI",
|
||||
"system": "Sistema",
|
||||
"about": "Acerca de",
|
||||
"status": "Estado",
|
||||
"localPreferences": "Preferencias locales",
|
||||
"presets": "Preajustes",
|
||||
@ -393,6 +394,13 @@
|
||||
"unavailable": "No disponible",
|
||||
"noDescription": "Sin descripción disponible."
|
||||
},
|
||||
"about": {
|
||||
"version": "Versión",
|
||||
"checking": "Buscando actualizaciones...",
|
||||
"checkForUpdates": "Buscar actualizaciones",
|
||||
"upToDate": "Estás al día",
|
||||
"updateAvailable": "Actualización disponible v{{version}}"
|
||||
},
|
||||
"mcp": {
|
||||
"allCategories": "Todas las categorías",
|
||||
"summary": "{{installed}} de {{total}} presets habilitados",
|
||||
|
||||
@ -88,6 +88,7 @@
|
||||
"interface": "Interface utilisateur",
|
||||
"ai": "AI",
|
||||
"system": "Système",
|
||||
"about": "À propos",
|
||||
"status": "État",
|
||||
"localPreferences": "Préférences locales",
|
||||
"presets": "Préréglages",
|
||||
@ -393,6 +394,13 @@
|
||||
"unavailable": "Indisponible",
|
||||
"noDescription": "Aucune description disponible."
|
||||
},
|
||||
"about": {
|
||||
"version": "Version",
|
||||
"checking": "Recherche de mises à jour...",
|
||||
"checkForUpdates": "Rechercher les mises à jour",
|
||||
"upToDate": "Vous êtes à jour",
|
||||
"updateAvailable": "Mise à jour disponible v{{version}}"
|
||||
},
|
||||
"mcp": {
|
||||
"allCategories": "Toutes les catégories",
|
||||
"summary": "{{installed}} presets activés sur {{total}}",
|
||||
|
||||
@ -88,6 +88,7 @@
|
||||
"interface": "Antarmuka",
|
||||
"ai": "AI",
|
||||
"system": "Sistem",
|
||||
"about": "Tentang",
|
||||
"status": "Status",
|
||||
"localPreferences": "Preferensi lokal",
|
||||
"presets": "Preset",
|
||||
@ -393,6 +394,13 @@
|
||||
"unavailable": "Tidak tersedia",
|
||||
"noDescription": "Tidak ada deskripsi."
|
||||
},
|
||||
"about": {
|
||||
"version": "Versi",
|
||||
"checking": "Memeriksa pembaruan...",
|
||||
"checkForUpdates": "Periksa pembaruan",
|
||||
"upToDate": "Anda sudah menggunakan versi terbaru",
|
||||
"updateAvailable": "Pembaruan tersedia v{{version}}"
|
||||
},
|
||||
"mcp": {
|
||||
"allCategories": "Semua kategori",
|
||||
"summary": "{{installed}} dari {{total}} preset diaktifkan",
|
||||
|
||||
@ -88,6 +88,7 @@
|
||||
"interface": "インターフェース",
|
||||
"ai": "AI",
|
||||
"system": "システム",
|
||||
"about": "情報",
|
||||
"status": "状態",
|
||||
"localPreferences": "ローカル設定",
|
||||
"presets": "プリセット",
|
||||
@ -393,6 +394,13 @@
|
||||
"unavailable": "利用不可",
|
||||
"noDescription": "説明はありません。"
|
||||
},
|
||||
"about": {
|
||||
"version": "バージョン",
|
||||
"checking": "確認中...",
|
||||
"checkForUpdates": "アップデートを確認",
|
||||
"upToDate": "最新の状態です",
|
||||
"updateAvailable": "アップデートがあります v{{version}}"
|
||||
},
|
||||
"mcp": {
|
||||
"allCategories": "すべてのカテゴリ",
|
||||
"summary": "{{total}} 個中 {{installed}} 個のプリセットが有効",
|
||||
|
||||
@ -88,6 +88,7 @@
|
||||
"interface": "인터페이스",
|
||||
"ai": "AI",
|
||||
"system": "시스템",
|
||||
"about": "정보",
|
||||
"status": "상태",
|
||||
"localPreferences": "로컬 환경설정",
|
||||
"presets": "프리셋",
|
||||
@ -393,6 +394,13 @@
|
||||
"unavailable": "사용 불가",
|
||||
"noDescription": "설명이 없습니다."
|
||||
},
|
||||
"about": {
|
||||
"version": "버전",
|
||||
"checking": "확인 중...",
|
||||
"checkForUpdates": "업데이트 확인",
|
||||
"upToDate": "최신 버전입니다",
|
||||
"updateAvailable": "업데이트 사용 가능 v{{version}}"
|
||||
},
|
||||
"mcp": {
|
||||
"allCategories": "모든 카테고리",
|
||||
"summary": "프리셋 {{total}}개 중 {{installed}}개 활성화됨",
|
||||
|
||||
@ -88,6 +88,7 @@
|
||||
"interface": "Giao diện",
|
||||
"ai": "AI",
|
||||
"system": "Hệ thống",
|
||||
"about": "Giới thiệu",
|
||||
"status": "Trạng thái",
|
||||
"localPreferences": "Tùy chọn cục bộ",
|
||||
"presets": "Preset",
|
||||
@ -393,6 +394,13 @@
|
||||
"unavailable": "Không khả dụng",
|
||||
"noDescription": "Không có mô tả."
|
||||
},
|
||||
"about": {
|
||||
"version": "Phiên bản",
|
||||
"checking": "Đang kiểm tra...",
|
||||
"checkForUpdates": "Kiểm tra bản cập nhật",
|
||||
"upToDate": "Bạn đang dùng phiên bản mới nhất",
|
||||
"updateAvailable": "Có bản cập nhật v{{version}}"
|
||||
},
|
||||
"mcp": {
|
||||
"allCategories": "Tất cả danh mục",
|
||||
"summary": "Đã bật {{installed}} / {{total}} preset",
|
||||
|
||||
@ -88,6 +88,7 @@
|
||||
"interface": "界面",
|
||||
"ai": "AI",
|
||||
"system": "系统",
|
||||
"about": "关于",
|
||||
"status": "状态",
|
||||
"localPreferences": "本地偏好",
|
||||
"presets": "预设",
|
||||
@ -253,6 +254,13 @@
|
||||
"unavailable": "不可用",
|
||||
"noDescription": "暂无描述。"
|
||||
},
|
||||
"about": {
|
||||
"version": "版本",
|
||||
"checking": "正在检查...",
|
||||
"checkForUpdates": "检查更新",
|
||||
"upToDate": "已是最新版本",
|
||||
"updateAvailable": "有可用更新 v{{version}}"
|
||||
},
|
||||
"mcp": {
|
||||
"allCategories": "全部分类",
|
||||
"summary": "已启用 {{installed}} / {{total}} 个预设",
|
||||
|
||||
@ -88,6 +88,7 @@
|
||||
"interface": "介面",
|
||||
"ai": "AI",
|
||||
"system": "系統",
|
||||
"about": "關於",
|
||||
"status": "狀態",
|
||||
"localPreferences": "本機偏好",
|
||||
"presets": "預設",
|
||||
@ -393,6 +394,13 @@
|
||||
"unavailable": "不可用",
|
||||
"noDescription": "暫無描述。"
|
||||
},
|
||||
"about": {
|
||||
"version": "版本",
|
||||
"checking": "正在檢查...",
|
||||
"checkForUpdates": "檢查更新",
|
||||
"upToDate": "已是最新版本",
|
||||
"updateAvailable": "有可用更新 v{{version}}"
|
||||
},
|
||||
"mcp": {
|
||||
"allCategories": "全部分類",
|
||||
"summary": "已啟用 {{installed}} / {{total}} 個預設",
|
||||
|
||||
@ -66,6 +66,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
||||
"settings.sections.webuiSafety",
|
||||
"settings.sections.capabilities",
|
||||
"settings.sections.apps",
|
||||
"settings.sections.about",
|
||||
"settings.rows.theme",
|
||||
"settings.rows.language",
|
||||
"settings.rows.density",
|
||||
@ -101,6 +102,10 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
||||
"settings.status.upToDate",
|
||||
"settings.actions.save",
|
||||
"settings.actions.saving",
|
||||
"settings.about.checking",
|
||||
"settings.about.checkForUpdates",
|
||||
"settings.about.upToDate",
|
||||
"settings.about.updateAvailable",
|
||||
];
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
@ -244,6 +249,7 @@ describe("webui i18n", () => {
|
||||
for (const key of SETTINGS_NAV_KEYS) {
|
||||
expect(common.settings.nav[key as keyof typeof common.settings.nav]).toBeTruthy();
|
||||
}
|
||||
expect(common.settings.sections.about).toBeTruthy();
|
||||
expect(common.settings.rows.theme).toBeTruthy();
|
||||
expect(common.settings.status.loading).toBeTruthy();
|
||||
expect(common.settings.actions.save).toBeTruthy();
|
||||
@ -255,6 +261,9 @@ describe("webui i18n", () => {
|
||||
expect(common.settings.byok.showApiKey).toBeTruthy();
|
||||
expect(common.settings.byok.hideApiKey).toBeTruthy();
|
||||
expect(common.settings.byok.configuredKeyHint).toBeTruthy();
|
||||
expect(common.settings.about.version).toBeTruthy();
|
||||
expect(common.settings.about.checkForUpdates).toBeTruthy();
|
||||
expect(common.settings.about.updateAvailable).toContain("{{version}}");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@ -134,6 +134,28 @@ function mockBlobUrls() {
|
||||
});
|
||||
}
|
||||
|
||||
function stubVisualViewport({
|
||||
height,
|
||||
offsetTop = 0,
|
||||
}: {
|
||||
height: number;
|
||||
offsetTop?: number;
|
||||
}) {
|
||||
const target = new EventTarget();
|
||||
vi.stubGlobal("visualViewport", {
|
||||
width: 390,
|
||||
height,
|
||||
offsetTop,
|
||||
offsetLeft: 0,
|
||||
pageTop: offsetTop,
|
||||
pageLeft: 0,
|
||||
scale: 1,
|
||||
addEventListener: target.addEventListener.bind(target),
|
||||
removeEventListener: target.removeEventListener.bind(target),
|
||||
dispatchEvent: target.dispatchEvent.bind(target),
|
||||
} as unknown as VisualViewport);
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
@ -1124,6 +1146,33 @@ describe("ThreadComposer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the slash command palette above a keyboard-constrained visual viewport", async () => {
|
||||
vi.spyOn(HTMLFormElement.prototype, "getBoundingClientRect").mockReturnValue(
|
||||
rect({ top: 120, bottom: 220, width: 390, height: 100 }),
|
||||
);
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
value: 800,
|
||||
configurable: true,
|
||||
});
|
||||
stubVisualViewport({ height: 300 });
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Ask anything..."
|
||||
slashCommands={COMMANDS}
|
||||
/>,
|
||||
);
|
||||
const input = screen.getByLabelText("Message input");
|
||||
|
||||
fireEvent.change(input, { target: { value: "/" } });
|
||||
|
||||
await waitFor(() => {
|
||||
const palette = screen.getByRole("listbox", { name: "Slash commands" });
|
||||
expect(palette.className).toContain("bottom-full");
|
||||
expect(palette).toHaveStyle({ maxHeight: "112px" });
|
||||
});
|
||||
});
|
||||
|
||||
it("dismisses the slash command palette on outside click", () => {
|
||||
render(
|
||||
<div>
|
||||
|
||||
@ -29,6 +29,59 @@ interface ResizeObserverInstance {
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
}
|
||||
|
||||
function stubVisualViewport({
|
||||
height,
|
||||
innerHeight,
|
||||
offsetTop = 0,
|
||||
}: {
|
||||
height: number;
|
||||
innerHeight: number;
|
||||
offsetTop?: number;
|
||||
}) {
|
||||
const originalInnerHeight = window.innerHeight;
|
||||
const originalVisualViewport = window.visualViewport;
|
||||
const target = new EventTarget();
|
||||
const viewport = {
|
||||
width: 390,
|
||||
height,
|
||||
offsetTop,
|
||||
offsetLeft: 0,
|
||||
pageTop: offsetTop,
|
||||
pageLeft: 0,
|
||||
scale: 1,
|
||||
addEventListener: target.addEventListener.bind(target),
|
||||
removeEventListener: target.removeEventListener.bind(target),
|
||||
dispatchEvent: target.dispatchEvent.bind(target),
|
||||
} as unknown as VisualViewport;
|
||||
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: innerHeight,
|
||||
});
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
configurable: true,
|
||||
value: viewport,
|
||||
});
|
||||
|
||||
return {
|
||||
viewport,
|
||||
restore: () => {
|
||||
Object.defineProperty(window, "innerHeight", {
|
||||
configurable: true,
|
||||
value: originalInnerHeight,
|
||||
});
|
||||
if (originalVisualViewport) {
|
||||
Object.defineProperty(window, "visualViewport", {
|
||||
configurable: true,
|
||||
value: originalVisualViewport,
|
||||
});
|
||||
} else {
|
||||
Reflect.deleteProperty(window, "visualViewport");
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function makeLongMessages(count: number): UIMessage[] {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
id: `m${index}`,
|
||||
@ -57,6 +110,21 @@ function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) {
|
||||
}
|
||||
|
||||
describe("ThreadViewport", () => {
|
||||
it("bottom-aligns short history near the composer", () => {
|
||||
render(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div>composer</div>}
|
||||
/>,
|
||||
);
|
||||
|
||||
const messageRegion = screen.getByTestId("thread-message-region");
|
||||
expect(messageRegion).toHaveClass("justify-end");
|
||||
expect(messageRegion).toHaveClass("pb-4");
|
||||
expect(messageRegion.className).not.toContain("5rem");
|
||||
});
|
||||
|
||||
it("keeps the scroll-to-bottom button above a growing composer", () => {
|
||||
const originalResizeObserver = globalThis.ResizeObserver;
|
||||
const resizeObservers: ResizeObserverInstance[] = [];
|
||||
@ -129,6 +197,124 @@ describe("ThreadViewport", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the thread scrollport above a mobile soft keyboard", async () => {
|
||||
const visualViewport = stubVisualViewport({ innerHeight: 800, height: 480 });
|
||||
try {
|
||||
const { container } = render(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<textarea aria-label="Message input" />}
|
||||
/>,
|
||||
);
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2400 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, value: 0 },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
act(() => {
|
||||
input.focus();
|
||||
fireEvent.focusIn(input);
|
||||
});
|
||||
|
||||
await waitFor(() => expect(scroller).toHaveStyle({ bottom: "320px" }));
|
||||
expect(screen.queryByRole("button", { name: "Scroll to bottom" })).not.toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
visualViewport.viewport.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
expect(scroller).toHaveStyle({ bottom: "320px" });
|
||||
} finally {
|
||||
visualViewport.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("scrolls recent messages into view when the composer receives focus", async () => {
|
||||
const scrollTo = vi.fn();
|
||||
const { container } = render(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<textarea aria-label="Message input" />}
|
||||
/>,
|
||||
);
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2400 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, writable: true, value: 0 },
|
||||
scrollTo: { configurable: true, value: scrollTo },
|
||||
});
|
||||
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
act(() => {
|
||||
input.focus();
|
||||
fireEvent.focusIn(input);
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 1800,
|
||||
behavior: "auto",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("scrolls recent messages into view when the focused composer resizes the visual viewport without an inset", async () => {
|
||||
const visualViewport = stubVisualViewport({ innerHeight: 500, height: 500 });
|
||||
const scrollTo = vi.fn();
|
||||
|
||||
try {
|
||||
const { container } = render(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<textarea aria-label="Message input" />}
|
||||
/>,
|
||||
);
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2400 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, writable: true, value: 0 },
|
||||
scrollTo: { configurable: true, value: scrollTo },
|
||||
});
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
Object.defineProperty(document, "activeElement", {
|
||||
configurable: true,
|
||||
get: () => input,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
visualViewport.viewport.dispatchEvent(new Event("resize"));
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 1800,
|
||||
behavior: "auto",
|
||||
}),
|
||||
);
|
||||
expect(scroller).not.toHaveStyle({ bottom: "320px" });
|
||||
} finally {
|
||||
Reflect.deleteProperty(document, "activeElement");
|
||||
visualViewport.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("hides the scroll-to-bottom button when disabled for the welcome view", () => {
|
||||
const { container } = render(
|
||||
<ThreadViewport
|
||||
@ -455,148 +641,132 @@ describe("ThreadViewport", () => {
|
||||
});
|
||||
|
||||
it("resets to the bottom when opening a different conversation", async () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
HTMLElement.prototype.scrollIntoView = scrollIntoView;
|
||||
const scrollTo = vi.fn();
|
||||
const { container, rerender } = render(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
conversationKey="chat-a"
|
||||
/>,
|
||||
);
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2400 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, writable: true, value: 0 },
|
||||
scrollTo: { configurable: true, value: scrollTo },
|
||||
});
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
try {
|
||||
const { container, rerender } = render(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
conversationKey="chat-a"
|
||||
/>,
|
||||
);
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2400 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, value: 0 },
|
||||
});
|
||||
act(() => {
|
||||
scroller.dispatchEvent(new Event("scroll"));
|
||||
});
|
||||
scrollIntoView.mockClear();
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
conversationKey="chat-b"
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
conversationKey="chat-b"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
block: "end",
|
||||
behavior: "auto",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 1800,
|
||||
behavior: "auto",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("waits for hydrated messages before fulfilling open-chat bottom scroll", async () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
HTMLElement.prototype.scrollIntoView = scrollIntoView;
|
||||
const scrollTo = vi.fn();
|
||||
const { container, rerender } = render(
|
||||
<ThreadViewport
|
||||
messages={emptyMessages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
conversationKey={null}
|
||||
/>,
|
||||
);
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 0 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, writable: true, value: 0 },
|
||||
scrollTo: { configurable: true, value: scrollTo },
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
try {
|
||||
const { container, rerender } = render(
|
||||
<ThreadViewport
|
||||
messages={emptyMessages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
conversationKey={null}
|
||||
/>,
|
||||
);
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperty(scroller, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 0,
|
||||
});
|
||||
scrollIntoView.mockClear();
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={emptyMessages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
conversationKey="chat-a"
|
||||
/>,
|
||||
);
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 0,
|
||||
behavior: "auto",
|
||||
});
|
||||
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={emptyMessages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
conversationKey="chat-a"
|
||||
/>,
|
||||
);
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
block: "end",
|
||||
Object.defineProperty(scroller, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 2400,
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
conversationKey="chat-a"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 1800,
|
||||
behavior: "auto",
|
||||
});
|
||||
|
||||
Object.defineProperty(scroller, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 2400,
|
||||
});
|
||||
scrollIntoView.mockClear();
|
||||
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
conversationKey="chat-a"
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
block: "end",
|
||||
behavior: "auto",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
|
||||
}
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("scrolls to the bottom when explicitly signalled after send", async () => {
|
||||
const scrollIntoView = vi.fn();
|
||||
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
|
||||
HTMLElement.prototype.scrollIntoView = scrollIntoView;
|
||||
const scrollTo = vi.fn();
|
||||
const { container, rerender } = render(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
scrollToBottomSignal={0}
|
||||
/>,
|
||||
);
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperties(scroller, {
|
||||
scrollHeight: { configurable: true, value: 2400 },
|
||||
clientHeight: { configurable: true, value: 600 },
|
||||
scrollTop: { configurable: true, writable: true, value: 0 },
|
||||
scrollTo: { configurable: true, value: scrollTo },
|
||||
});
|
||||
scrollTo.mockClear();
|
||||
|
||||
try {
|
||||
const { container, rerender } = render(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
scrollToBottomSignal={0}
|
||||
/>,
|
||||
);
|
||||
const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
|
||||
Object.defineProperty(scroller, "scrollHeight", {
|
||||
configurable: true,
|
||||
value: 2400,
|
||||
});
|
||||
scrollIntoView.mockClear();
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
scrollToBottomSignal={1}
|
||||
/>,
|
||||
);
|
||||
|
||||
rerender(
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={false}
|
||||
composer={<div />}
|
||||
scrollToBottomSignal={1}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(scrollIntoView).toHaveBeenCalledWith({
|
||||
block: "end",
|
||||
behavior: "auto",
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(scrollTo).toHaveBeenCalledWith({
|
||||
top: 1800,
|
||||
behavior: "auto",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user