Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	webui/src/components/settings/SettingsView.tsx
This commit is contained in:
chengyongru
2026-06-15 18:11:22 +08:00
45 changed files with 1010 additions and 203 deletions
+11 -1
View File
@@ -1,4 +1,7 @@
![nanobot README cover](./images/readme-cover.png) <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"> <div align="center">
<p> <p>
@@ -44,6 +47,13 @@
| Configure providers, fallback models, Langfuse, MCP, web tools, or security | [Docs](./docs/README.md) and [Configuration](./docs/configuration.md) | | 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) | | 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 ## 📢 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. - **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.
+1 -1
View File
@@ -231,7 +231,7 @@ Tracing covers the providers that go through nanobot's OpenAI-compatible client
| `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) | | `siliconflow` | LLM (SiliconFlow/硅基流动) | [siliconflow.cn](https://siliconflow.cn) |
| `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) | | `novita` | LLM (Novita AI OpenAI-compatible gateway) | [novita.ai](https://novita.ai) |
| `dashscope` | LLM (Qwen) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `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) | | `zhipu` | LLM (Zhipu GLM) | [open.bigmodel.cn](https://open.bigmodel.cn) |
| `xiaomi_mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) | | `xiaomi_mimo` | LLM (MiMo) | [platform.xiaomimimo.com](https://platform.xiaomimimo.com) |
| `longcat` | LLM (LongCat) | [longcat.chat](https://longcat.chat/platform/docs/zh/) | | `longcat` | LLM (LongCat) | [longcat.chat](https://longcat.chat/platform/docs/zh/) |
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 166 KiB

+22 -1
View File
@@ -61,6 +61,7 @@ class MemoryStore:
self._cursor_file = self.memory_dir / ".cursor" self._cursor_file = self.memory_dir / ".cursor"
self._dream_cursor_file = self.memory_dir / ".dream_cursor" self._dream_cursor_file = self.memory_dir / ".dream_cursor"
self._corruption_logged = False # rate-limit non-int cursor warning 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._oversize_logged = False # rate-limit oversized-entry warning
self._append_lock = threading.Lock() # serialize cursor allocation + append self._append_lock = threading.Lock() # serialize cursor allocation + append
self._git = GitStore(workspace, tracked_files=[ self._git = GitStore(workspace, tracked_files=[
@@ -295,8 +296,9 @@ class MemoryStore:
return value return value
def _iter_valid_entries(self) -> Iterator[tuple[dict[str, Any], int]]: 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 poisoned: Any = None
malformed_cursor: int | None = None
for entry in self._read_entries(): for entry in self._read_entries():
raw = entry.get("cursor") raw = entry.get("cursor")
if raw is None: if raw is None:
@@ -305,6 +307,9 @@ class MemoryStore:
if cursor is None: if cursor is None:
poisoned = raw poisoned = raw
continue continue
if not self._valid_history_payload(entry):
malformed_cursor = cursor
continue
yield entry, cursor yield entry, cursor
if poisoned is not None and not self._corruption_logged: if poisoned is not None and not self._corruption_logged:
self._corruption_logged = True self._corruption_logged = True
@@ -313,6 +318,22 @@ class MemoryStore:
"Usually caused by an external writer; further occurrences suppressed.", "Usually caused by an external writer; further occurrences suppressed.",
poisoned, 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: def _next_cursor(self) -> int:
"""Read the current cursor counter and return the next value.""" """Read the current cursor counter and return the next value."""
+18 -3
View File
@@ -257,12 +257,17 @@ class AgentRunner:
return [] return []
injected_messages: list[dict[str, Any]] = [] injected_messages: list[dict[str, Any]] = []
for item in items: for item in items:
if item is None:
continue
if isinstance(item, dict) and item.get("role") == "user" and "content" in item: 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) injected_messages.append(item)
continue continue
text = getattr(item, "content", str(item)) if isinstance(item, dict):
if text.strip(): continue
injected_messages.append({"role": "user", "content": text}) 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: if len(injected_messages) > _MAX_INJECTIONS_PER_TURN:
dropped = len(injected_messages) - _MAX_INJECTIONS_PER_TURN dropped = len(injected_messages) - _MAX_INJECTIONS_PER_TURN
logger.warning( logger.warning(
@@ -272,6 +277,16 @@ class AgentRunner:
injected_messages = injected_messages[:_MAX_INJECTIONS_PER_TURN] injected_messages = injected_messages[:_MAX_INJECTIONS_PER_TURN]
return injected_messages 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: async def run(self, spec: AgentRunSpec) -> AgentRunResult:
hook = spec.hook or AgentHook() hook = spec.hook or AgentHook()
messages = list(spec.initial_messages) messages = list(spec.initial_messages)
+5 -4
View File
@@ -16,16 +16,16 @@ from nanobot.agent.tools.context import ToolContext
from nanobot.agent.tools.file_state import FileStates from nanobot.agent.tools.file_state import FileStates
from nanobot.agent.tools.loader import ToolLoader from nanobot.agent.tools.loader import ToolLoader
from nanobot.agent.tools.registry import ToolRegistry 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 ( from nanobot.security.workspace_access import (
WorkspaceScope, WorkspaceScope,
bind_workspace_scope, bind_workspace_scope,
reset_workspace_scope, reset_workspace_scope,
workspace_sandbox_status, 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 from nanobot.utils.prompt_templates import render_template
@@ -118,6 +118,7 @@ class SubagentManager:
return ToolsConfig( return ToolsConfig(
exec=self.tools_config.exec, exec=self.tools_config.exec,
web=self.tools_config.web, web=self.tools_config.web,
file=self.tools_config.file,
restrict_to_workspace=self.restrict_to_workspace, restrict_to_workspace=self.restrict_to_workspace,
) )
+18 -1
View File
@@ -10,19 +10,36 @@ from typing import Any
from nanobot.agent.tools.base import Tool, tool_parameters 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.file_state import FileStates, _hash_file, current_file_states
from nanobot.agent.tools.path_utils import resolve_workspace_path 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 ( from nanobot.agent.tools.schema import (
BooleanSchema, BooleanSchema,
IntegerSchema, IntegerSchema,
StringSchema, StringSchema,
tool_parameters_schema, 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 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): class _FsTool(Tool):
"""Shared base for filesystem tools — common init and path resolution.""" """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__( def __init__(
self, self,
workspace: Path | None = None, workspace: Path | None = None,
+16 -3
View File
@@ -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 { return {
"id": f"chatcmpl-{uuid.uuid4().hex[:12]}", "id": f"chatcmpl-{uuid.uuid4().hex[:12]}",
"object": "chat.completion", "object": "chat.completion",
@@ -67,7 +74,11 @@ def _chat_completion_response(content: str, model: str) -> dict[str, Any]:
"finish_reason": "stop", "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) logger.exception("Unexpected API lock error for session {}", session_key)
return _error_json(500, "Internal server error", err_type="server_error") 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: async def handle_models(request: web.Request) -> web.Response:
+2 -1
View File
@@ -1282,7 +1282,8 @@ def agent(
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
_init_prompt_session() _init_prompt_session()
_model, _preset_tag = _model_display(config) _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: if ":" in session_id:
cli_channel, cli_chat_id = session_id.split(":", 1) cli_channel, cli_chat_id = session_id.split(":", 1)
+4
View File
@@ -12,6 +12,7 @@ from nanobot.cron.types import CronSchedule
if TYPE_CHECKING: if TYPE_CHECKING:
from nanobot.agent.tools.cli_apps import CliAppsToolConfig 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.image_generation import ImageGenerationToolConfig
from nanobot.agent.tools.self import MyToolConfig from nanobot.agent.tools.self import MyToolConfig
from nanobot.agent.tools.shell import ExecToolConfig 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")) 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")) 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")) 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")) my: MyToolConfig = Field(default_factory=lambda: _lazy_default("nanobot.agent.tools.self", "MyToolConfig"))
image_generation: ImageGenerationToolConfig = Field( image_generation: ImageGenerationToolConfig = Field(
@@ -558,6 +560,7 @@ def _resolve_tool_config_refs() -> None:
import sys import sys
from nanobot.agent.tools.cli_apps import CliAppsToolConfig 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.image_generation import ImageGenerationToolConfig
from nanobot.agent.tools.self import MyToolConfig from nanobot.agent.tools.self import MyToolConfig
from nanobot.agent.tools.shell import ExecToolConfig from nanobot.agent.tools.shell import ExecToolConfig
@@ -566,6 +569,7 @@ def _resolve_tool_config_refs() -> None:
# Re-export into this module's namespace # Re-export into this module's namespace
mod = sys.modules[__name__] mod = sys.modules[__name__]
mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined] mod.ExecToolConfig = ExecToolConfig # type: ignore[attr-defined]
mod.FileToolsConfig = FileToolsConfig # type: ignore[attr-defined]
mod.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined] mod.CliAppsToolConfig = CliAppsToolConfig # type: ignore[attr-defined]
mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined] mod.WebToolsConfig = WebToolsConfig # type: ignore[attr-defined]
mod.WebSearchConfig = WebSearchConfig # type: ignore[attr-defined] mod.WebSearchConfig = WebSearchConfig # type: ignore[attr-defined]
+4 -3
View File
@@ -452,9 +452,10 @@ class AnthropicProvider(LLMProvider):
max_tokens = max(1, max_tokens) max_tokens = max(1, max_tokens)
thinking_enabled = bool(reasoning_effort) and reasoning_effort.lower() != "none" thinking_enabled = bool(reasoning_effort) and reasoning_effort.lower() != "none"
# claude-opus-4-7 deprecated the `temperature` parameter entirely — the # Several Anthropic models (opus-4-7, opus-4-8, fable) deprecated the
# API returns 400 if it is present, on any code path. # `temperature` parameter — the API returns 400 if it is present.
omit_temperature = "opus-4-7" in model_name _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] = { kwargs: dict[str, Any] = {
"model": model_name, "model": model_name,
+2
View File
@@ -1370,6 +1370,8 @@ async def _parse_codex_sse_images(
logger.error("Codex SSE failure: {}", raw[:2000]) logger.error("Codex SSE failure: {}", raw[:2000])
_collect_images_from_sse_event(event, images) _collect_images_from_sse_event(event, images)
_collect_text_from_sse_event(event, text_parts) _collect_text_from_sse_event(event, text_parts)
if ev_type == "response.completed":
break
continue continue
buffer.append(line) buffer.append(line)
+15
View File
@@ -87,6 +87,21 @@ class TestBuildDreamPrompt:
assert "entry-21" in next_prompt assert "entry-21" in next_prompt
assert "entry-25" 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): def test_dream_prompt_consumes_consolidator_attribute_tags(self):
prompt = render_template( prompt = render_template(
"agent/dream.md", "agent/dream.md",
+17
View File
@@ -171,6 +171,23 @@ class TestHistoryWithCursor:
entries = store.read_unprocessed_history(since_cursor=0) entries = store.read_unprocessed_history(since_cursor=0)
assert [e["cursor"] for e in entries] == [2, 3] 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): def test_next_cursor_falls_back_when_last_entry_has_no_cursor(self, store):
"""Regression: _next_cursor should not KeyError on entries without cursor.""" """Regression: _next_cursor should not KeyError on entries without cursor."""
store.history_file.write_text( store.history_file.write_text(
+64 -1
View File
@@ -152,6 +152,70 @@ async def test_drain_injections_skips_empty_content():
assert result == [{"role": "user", "content": "valid"}] 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 @pytest.mark.asyncio
async def test_drain_injections_handles_callback_exception(): async def test_drain_injections_handles_callback_exception():
"""If the callback raises, return empty list (error is logged).""" """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 assert result.had_injections is True
# Should cap: _MAX_INJECTION_CYCLES drained rounds + 1 final round that breaks # Should cap: _MAX_INJECTION_CYCLES drained rounds + 1 final round that breaks
assert call_count["n"] == _MAX_INJECTION_CYCLES + 1 assert call_count["n"] == _MAX_INJECTION_CYCLES + 1
+28
View File
@@ -6,7 +6,9 @@ from unittest.mock import MagicMock
import pytest import pytest
from nanobot.agent.subagent import SubagentManager from nanobot.agent.subagent import SubagentManager
from nanobot.agent.tools.filesystem import FileToolsConfig
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.config.schema import ToolsConfig
from nanobot.providers.base import LLMProvider 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") second_result = await second_read.execute(path="note.txt")
assert second_result.startswith("1| hello") assert second_result.startswith("1| hello")
assert "File unchanged" not in second_result 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 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: def test_reasoning_effort_string_none_does_not_enable_thinking() -> None:
"""reasoning_effort='none' must not enable thinking — treated same as disabled.""" """reasoning_effort='none' must not enable thinking — treated same as disabled."""
kw = _build(_make_provider(), "none") kw = _build(_make_provider(), "none")
+46
View File
@@ -84,6 +84,23 @@ class FakeClient:
return self.get_response 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 @pytest.mark.asyncio
async def test_openrouter_image_generation_payload_and_response(tmp_path: Path) -> None: async def test_openrouter_image_generation_payload_and_response(tmp_path: Path) -> None:
ref = tmp_path / "ref.png" ref = tmp_path / "ref.png"
@@ -1024,6 +1041,35 @@ async def test_codex_payload_and_response(monkeypatch) -> None:
assert body["stream"] is True 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 @pytest.mark.asyncio
async def test_codex_strips_model_prefix(monkeypatch) -> None: async def test_codex_strips_model_prefix(monkeypatch) -> None:
import sys import sys
+1
View File
@@ -32,6 +32,7 @@ def _make_mock_agent(response_text: str = "mock response") -> MagicMock:
agent.process_direct = AsyncMock(return_value=response_text) agent.process_direct = AsyncMock(return_value=response_text)
agent._connect_mcp = AsyncMock() agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock() agent.close_mcp = AsyncMock()
agent._last_usage = {}
return agent return agent
+8
View File
@@ -75,6 +75,7 @@ def _make_streaming_agent(tokens: list[str]) -> MagicMock:
return " ".join(tokens) return " ".join(tokens)
agent.process_direct = fake_process_direct agent.process_direct = fake_process_direct
agent._last_usage = {}
return agent 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.process_direct = AsyncMock(return_value="normal reply")
agent._connect_mcp = AsyncMock() agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock() agent.close_mcp = AsyncMock()
agent._last_usage = {}
app = create_app(agent, model_name="m") app = create_app(agent, model_name="m")
client = await aiohttp_client(app) 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.process_direct = AsyncMock(return_value="default reply")
agent._connect_mcp = AsyncMock() agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock() agent.close_mcp = AsyncMock()
agent._last_usage = {}
app = create_app(agent, model_name="m") app = create_app(agent, model_name="m")
client = await aiohttp_client(app) 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.process_direct = fake_process_direct
agent._connect_mcp = AsyncMock() agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock() agent.close_mcp = AsyncMock()
agent._last_usage = {}
app = create_app(agent, model_name="m") app = create_app(agent, model_name="m")
client = await aiohttp_client(app) 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.process_direct = fake_process_direct
agent._connect_mcp = AsyncMock() agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock() agent.close_mcp = AsyncMock()
agent._last_usage = {}
app = create_app(agent, model_name="m") app = create_app(agent, model_name="m")
client = await aiohttp_client(app) 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.process_direct = fake_process_direct
agent._connect_mcp = AsyncMock() agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock() agent.close_mcp = AsyncMock()
agent._last_usage = {}
app = create_app(agent, model_name="m") app = create_app(agent, model_name="m")
client = await aiohttp_client(app) 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.process_direct = fake_process_direct
agent._connect_mcp = AsyncMock() agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock() agent.close_mcp = AsyncMock()
agent._last_usage = {}
app = create_app(agent, model_name="m") app = create_app(agent, model_name="m")
client = await aiohttp_client(app) 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.process_direct = boom
agent._connect_mcp = AsyncMock() agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock() agent.close_mcp = AsyncMock()
agent._last_usage = {}
app = create_app(agent, model_name="m") app = create_app(agent, model_name="m")
client = await aiohttp_client(app) client = await aiohttp_client(app)
+44
View 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)
+24
View File
@@ -33,6 +33,7 @@ def _make_mock_agent(response_text: str = "mock response") -> MagicMock:
agent.process_direct = AsyncMock(return_value=response_text) agent.process_direct = AsyncMock(return_value=response_text)
agent._connect_mcp = AsyncMock() agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock() agent.close_mcp = AsyncMock()
agent._last_usage = {"prompt_tokens": 100, "completion_tokens": 50}
return agent return agent
@@ -78,6 +79,25 @@ def test_chat_completion_response() -> None:
assert result["choices"][0]["message"]["content"] == "hello world" assert result["choices"][0]["message"]["content"] == "hello world"
assert result["choices"][0]["finish_reason"] == "stop" assert result["choices"][0]["finish_reason"] == "stop"
assert result["id"].startswith("chatcmpl-") 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") @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.process_direct = fake_process
agent._connect_mcp = AsyncMock() agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock() agent.close_mcp = AsyncMock()
agent._last_usage = {}
app = create_app(agent, model_name="m") app = create_app(agent, model_name="m")
client = await aiohttp_client(app) 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.process_direct = slow_process
agent._connect_mcp = AsyncMock() agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock() agent.close_mcp = AsyncMock()
agent._last_usage = {}
app = create_app(agent, model_name="m") app = create_app(agent, model_name="m")
client = await aiohttp_client(app) 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.process_direct = sometimes_empty
agent._connect_mcp = AsyncMock() agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock() agent.close_mcp = AsyncMock()
agent._last_usage = {}
app = create_app(agent, model_name="m") app = create_app(agent, model_name="m")
client = await aiohttp_client(app) 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.process_direct = always_empty
agent._connect_mcp = AsyncMock() agent._connect_mcp = AsyncMock()
agent.close_mcp = AsyncMock() agent.close_mcp = AsyncMock()
agent._last_usage = {}
app = create_app(agent, model_name="m") app = create_app(agent, model_name="m")
client = await aiohttp_client(app) client = await aiohttp_client(app)
+3 -2
View File
@@ -70,6 +70,7 @@ const LEGACY_COMPLETED_RUNS_STORAGE_KEY = "nanobot-webui.sidebar.completed-runs.
const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt"; const RESTART_STARTED_KEY = "nanobot-webui.restartStartedAt";
const SIDEBAR_WIDTH = 272; const SIDEBAR_WIDTH = 272;
const SIDEBAR_RAIL_WIDTH = 56; 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_MARGIN_MS = 30_000;
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000; const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
type ShellView = "chat" | "settings" | "apps" | "automations" | "skills"; type ShellView = "chat" | "settings" | "apps" | "automations" | "skills";
@@ -1531,7 +1532,7 @@ function Shell({
showCloseButton={false} showCloseButton={false}
aria-describedby={undefined} aria-describedby={undefined}
className="p-0 lg:hidden" 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> <SheetTitle className="sr-only">{t("sidebar.navigation")}</SheetTitle>
<Sidebar <Sidebar
@@ -1637,7 +1638,7 @@ function Shell({
{restartToast ? ( {restartToast ? (
<div <div
role="status" 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} {restartToast}
</div> </div>
+1 -1
View File
@@ -80,7 +80,7 @@ export function DeleteConfirm({
</div> </div>
) : null} ) : null}
</AlertDialogHeader> </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 <AlertDialogCancel
onClick={onCancel} 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" 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"
+2 -2
View File
@@ -107,7 +107,7 @@ export function FilePreviewPanel({
"--file-preview-slot-width": !entered || isClosing ? "0px" : `${desktopWidth}px`, "--file-preview-slot-width": !entered || isClosing ? "0px" : `${desktopWidth}px`,
} as CSSProperties} } as CSSProperties}
className={cn( 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]", "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", "md:relative md:z-auto md:w-[var(--file-preview-slot-width)] md:min-w-0 md:shrink-0",
isClosing && "pointer-events-none", isClosing && "pointer-events-none",
@@ -117,7 +117,7 @@ export function FilePreviewPanel({
> >
<div <div
className={cn( 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", "border-l border-border/70 bg-background shadow-2xl md:shadow-none",
"transition-[opacity,transform] duration-300 ease-out will-change-transform", "transition-[opacity,transform] duration-300 ease-out will-change-transform",
!entered || isClosing ? "translate-x-full opacity-0" : "translate-x-0 opacity-100", !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]"> <main className="min-w-0 flex-1 overflow-y-auto [scrollbar-gutter:stable]">
<div <div
className={cn( 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" activeSection === "automations"
? "max-w-[1220px] 2xl:max-w-[1320px]" ? "max-w-[1220px] 2xl:max-w-[1320px]"
: "max-w-[920px]", : "max-w-[920px]",
@@ -1806,7 +1806,7 @@ function SettingsSidebar({
return ( return (
<aside <aside
className={cn( 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", 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" ? ( {result?.type === "update" ? (
<span className="inline-flex items-center gap-1.5 text-[12px] text-blue-600 dark:text-blue-300"> <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 /> <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 ? ( {result.pypiUrl ? (
<a <a
href={result.pypiUrl} href={result.pypiUrl}
@@ -7064,7 +7067,7 @@ function SettingsRow({
</div> </div>
) : null} ) : null}
</div> </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> </div>
); );
} }
@@ -7080,7 +7083,7 @@ function ReadOnlyRow({
}) { }) {
return ( return (
<SettingsRow title={title} description={description}> <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} {value}
</span> </span>
</SettingsRow> </SettingsRow>
@@ -7526,7 +7529,7 @@ function NumberInput({
const parsed = Number(event.target.value); const parsed = Number(event.target.value);
if (Number.isFinite(parsed)) onChange(parsed); 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} {suffix ? <span className="text-[12px] text-muted-foreground">{suffix}</span> : null}
</div> </div>
@@ -177,7 +177,7 @@ export function TokenUsageHeatmap({
return ( return (
<div className="overflow-x-auto pb-1 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"> <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"> <div className="mb-2 flex justify-end">
<span className="text-[11px] font-normal leading-none text-muted-foreground/64"> <span className="text-[11px] font-normal leading-none text-muted-foreground/64">
{tx("settings.usage.shortTitle", "Token Usage")} {tx("settings.usage.shortTitle", "Token Usage")}
@@ -195,7 +195,7 @@ export function TokenUsageHeatmap({
))} ))}
</div> </div>
<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))` }} style={{ gridTemplateColumns: `repeat(${TOKEN_HEATMAP_COLUMNS}, minmax(0, 1fr))` }}
aria-label={tx("settings.usage.title", "Token activity")} aria-label={tx("settings.usage.title", "Token activity")}
> >
@@ -224,7 +224,7 @@ export function TokenUsageHeatmap({
<span <span
aria-label={ariaLabel} aria-label={ariaLabel}
className={cn( 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), tokenUsageCellClass(level, cell.future),
)} )}
/> />
+43 -16
View File
@@ -419,9 +419,20 @@ function suppressNativeDragPreview(dataTransfer: DataTransfer): void {
window.setTimeout(() => ghost.remove(), 0); 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 } { function getVisibleBounds(el: HTMLElement): { top: number; bottom: number } {
let top = 0; const viewport = visualViewportBounds();
let bottom = window.innerHeight; let top = viewport.top;
let bottom = viewport.bottom;
let parent = el.parentElement; let parent = el.parentElement;
while (parent) { while (parent) {
@@ -455,11 +466,12 @@ const GOAL_PANEL_MIN_HEIGHT_PX = 112;
const GOAL_PANEL_MAX_VIEWPORT_RATIO = 0.62; const GOAL_PANEL_MAX_VIEWPORT_RATIO = 0.62;
function measureGoalPanelMaxCssHeight(stripTopY: number): number { function measureGoalPanelMaxCssHeight(stripTopY: number): number {
const viewport = visualViewportBounds();
const spaceAboveStrip = 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( return Math.min(
Math.max(spaceAboveStrip, GOAL_PANEL_MIN_HEIGHT_PX), 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) { if (stripWrapperRef.current && ro) {
ro.observe(stripWrapperRef.current); ro.observe(stripWrapperRef.current);
} }
const viewport = window.visualViewport;
viewport?.addEventListener("resize", relayout);
viewport?.addEventListener("scroll", relayout);
window.addEventListener("resize", relayout); window.addEventListener("resize", relayout);
window.addEventListener("scroll", relayout, true); window.addEventListener("scroll", relayout, true);
return () => { return () => {
ro?.disconnect(); ro?.disconnect();
viewport?.removeEventListener("resize", relayout);
viewport?.removeEventListener("scroll", relayout);
window.removeEventListener("resize", relayout); window.removeEventListener("resize", relayout);
window.removeEventListener("scroll", relayout, true); window.removeEventListener("scroll", relayout, true);
}; };
@@ -1111,9 +1128,14 @@ export function ThreadComposer({
}; };
updateLayout(); updateLayout();
const viewport = window.visualViewport;
viewport?.addEventListener("resize", updateLayout);
viewport?.addEventListener("scroll", updateLayout);
window.addEventListener("resize", updateLayout); window.addEventListener("resize", updateLayout);
document.addEventListener("scroll", updateLayout, true); document.addEventListener("scroll", updateLayout, true);
return () => { return () => {
viewport?.removeEventListener("resize", updateLayout);
viewport?.removeEventListener("scroll", updateLayout);
window.removeEventListener("resize", updateLayout); window.removeEventListener("resize", updateLayout);
document.removeEventListener("scroll", updateLayout, true); document.removeEventListener("scroll", updateLayout, true);
}; };
@@ -1544,10 +1566,10 @@ export function ThreadComposer({
"w-full resize-none bg-transparent", "w-full resize-none bg-transparent",
isHero isHero
? cn( ? 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", 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 ( return (
@@ -1699,11 +1721,13 @@ export function ThreadComposer({
) : null} ) : null}
<div <div
className={cn( className={cn(
"flex items-center justify-between", "flex flex-wrap items-center justify-between gap-y-2",
isHero ? cn("gap-1.5 px-4", showProjectPicker ? "pb-1.5" : "pb-3.5") : "gap-2 px-3 pb-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 <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
@@ -1746,7 +1770,7 @@ export function ThreadComposer({
/> />
) : null} ) : null}
</div> </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 ? ( {modelLabel && !voiceRecorder.isRecording ? (
<ComposerModelBadge <ComposerModelBadge
label={modelLabel} label={modelLabel}
@@ -1768,6 +1792,7 @@ export function ThreadComposer({
disabled={voiceRecorder.buttonDisabled} disabled={voiceRecorder.buttonDisabled}
aria-label={voiceButtonLabel} aria-label={voiceButtonLabel}
aria-keyshortcuts={VOICE_SHORTCUT_ARIA} aria-keyshortcuts={VOICE_SHORTCUT_ARIA}
title={voiceButtonTooltip}
onPointerDown={voiceRecorder.beginPress} onPointerDown={voiceRecorder.beginPress}
onPointerUp={voiceRecorder.endPress} onPointerUp={voiceRecorder.endPress}
onPointerCancel={voiceRecorder.endPress} onPointerCancel={voiceRecorder.endPress}
@@ -2071,7 +2096,9 @@ function ComposerModelBadge({
"shadow-[0_2px_8px_rgba(15,23,42,0.045)]", "shadow-[0_2px_8px_rgba(15,23,42,0.045)]",
interactive && "cursor-pointer hover:bg-accent/55 hover:text-foreground", 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", 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 <span
@@ -2252,7 +2279,7 @@ function CliAppMentionPalette({
onChoose(candidate); onChoose(candidate);
}} }}
className={cn( 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 selected
? "bg-foreground/[0.055] text-foreground" ? "bg-foreground/[0.055] text-foreground"
: "text-foreground/90 hover:bg-foreground/[0.04]", : "text-foreground/90 hover:bg-foreground/[0.04]",
@@ -2260,7 +2287,7 @@ function CliAppMentionPalette({
> >
<MentionCandidateLogo candidate={candidate} selected={selected} /> <MentionCandidateLogo candidate={candidate} selected={selected} />
<span className="flex min-w-0 flex-1 items-baseline gap-2"> <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} {displayName}
</span> </span>
<span className="truncate text-[15px] font-normal tracking-normal text-muted-foreground/72"> <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" /> <Icon className="h-4 w-4" />
</span> </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"> <span className="min-w-0 truncate text-[13.5px] font-semibold tracking-normal text-foreground">
{title} {title}
</span> </span>
@@ -2404,7 +2431,7 @@ function SlashCommandPalette({
{command.detail || description} {command.detail || description}
</span> </span>
</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 ? ( {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"> <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")} {command.badge ?? t("thread.composer.slash.badges.recent")}
@@ -2486,7 +2513,7 @@ function AttachmentChip({
) : null} ) : null}
</div> </div>
<div className="flex min-w-0 flex-col text-[11.5px] leading-4"> <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} {image.file.name}
</span> </span>
<span className="truncate text-muted-foreground"> <span className="truncate text-muted-foreground">
+1 -1
View File
@@ -725,7 +725,7 @@ 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">
<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)} {t(heroGreetingKey)}
</h1> </h1>
</div> </div>
+124 -14
View File
@@ -50,6 +50,8 @@ const NEAR_BOTTOM_PX = 48;
const NEAR_TOP_PX = 96; const NEAR_TOP_PX = 96;
const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192; const DEFAULT_SCROLL_BUTTON_BOTTOM_PX = 192;
const SCROLL_BUTTON_COMPOSER_GAP_PX = 16; 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 INITIAL_HISTORY_WINDOW = 160;
export const HISTORY_WINDOW_INCREMENT = 120; export const HISTORY_WINDOW_INCREMENT = 120;
@@ -66,6 +68,35 @@ export function windowMessages(messages: UIMessage[], visibleCount: number): UIM
return messages.slice(start); 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({ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
messages, messages,
isStreaming, isStreaming,
@@ -99,6 +130,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const userReadingHistoryRef = useRef(false); const userReadingHistoryRef = useRef(false);
const [atBottom, setAtBottom] = useState(true); const [atBottom, setAtBottom] = useState(true);
const [composerDockHeight, setComposerDockHeight] = useState(0); const [composerDockHeight, setComposerDockHeight] = useState(0);
const [keyboardInsetBottom, setKeyboardInsetBottom] = useState(0);
const [visibleMessageCount, setVisibleMessageCount] = const [visibleMessageCount, setVisibleMessageCount] =
useState(INITIAL_HISTORY_WINDOW); useState(INITIAL_HISTORY_WINDOW);
const hasMessages = messages.length > 0; const hasMessages = messages.length > 0;
@@ -116,9 +148,13 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount forkBoundaryMessageCount !== null && forkBoundaryMessageCount > hiddenMessageCount
? forkBoundaryMessageCount - hiddenMessageCount ? forkBoundaryMessageCount - hiddenMessageCount
: null; : null;
const scrollButtonBottom = composerDockHeight > 0 const scrollButtonBottom =
keyboardInsetBottom
+ (composerDockHeight > 0
? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX ? composerDockHeight + SCROLL_BUTTON_COMPOSER_GAP_PX
: DEFAULT_SCROLL_BUTTON_BOTTOM_PX; : DEFAULT_SCROLL_BUTTON_BOTTOM_PX);
const scrollViewportStyle =
keyboardInsetBottom > 0 ? { bottom: keyboardInsetBottom } : undefined;
const cancelScheduledBottomScroll = useCallback(() => { const cancelScheduledBottomScroll = useCallback(() => {
for (const id of scrollFrameIdsRef.current) { for (const id of scrollFrameIdsRef.current) {
@@ -131,10 +167,20 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
const el = scrollRef.current; const el = scrollRef.current;
const marker = bottomRef.current; const marker = bottomRef.current;
const behavior: ScrollBehavior = smooth ? "smooth" : "auto"; 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 }); marker.scrollIntoView({ block: "end", behavior });
} else if (el) {
el.scrollTo({ top: el.scrollHeight, behavior });
} }
userReadingHistoryRef.current = false; userReadingHistoryRef.current = false;
setAtBottom(true); setAtBottom(true);
@@ -148,14 +194,18 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
if (!force && userReadingHistoryRef.current) return; if (!force && userReadingHistoryRef.current) return;
scrollToBottomNow(smooth); scrollToBottomNow(smooth);
}; };
run(); const scheduleNext = (remainingFrames: number) => {
for (let i = 1; i < frames; i += 1) { if (remainingFrames <= 0) return;
const id = window.requestAnimationFrame(() => { const id = window.requestAnimationFrame(() => {
scrollFrameIdsRef.current = scrollFrameIdsRef.current.filter((frameId) => frameId !== id);
if (!force && userReadingHistoryRef.current) return; if (!force && userReadingHistoryRef.current) return;
scrollToBottomNow(smooth); scrollToBottomNow(smooth);
scheduleNext(remainingFrames - 1);
}); });
scrollFrameIdsRef.current.push(id); scrollFrameIdsRef.current.push(id);
} };
run();
scheduleNext(frames - 1);
}, },
[cancelScheduledBottomScroll, scrollToBottomNow], [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(() => { useEffect(() => {
if (!atBottom) return; if (!atBottom) return;
// Instant jump: CSS scroll-smooth + behavior "auto" still animates in some // Instant jump: CSS scroll-smooth + behavior "auto" still animates in some
@@ -223,6 +304,31 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
scrollToBottom(false); scrollToBottom(false);
}, [messages, atBottom, scrollToBottom]); }, [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(() => { useEffect(() => {
if (scrollToBottomSignal <= 0) return; if (scrollToBottomSignal <= 0) return;
userReadingHistoryRef.current = false; userReadingHistoryRef.current = false;
@@ -332,10 +438,14 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
"[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30", "[&::-webkit-scrollbar-thumb]:bg-muted-foreground/30",
"[&::-webkit-scrollbar-track]:bg-transparent", "[&::-webkit-scrollbar-track]:bg-transparent",
)} )}
style={scrollViewportStyle}
> >
{hasMessages ? ( {hasMessages ? (
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col"> <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]"> <div className="mx-auto w-full max-w-[49.5rem]">
<ThreadMessages <ThreadMessages
messages={visibleMessages} messages={visibleMessages}
@@ -355,16 +465,16 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
data-testid="thread-composer-dock" data-testid="thread-composer-dock"
className="sticky bottom-0 z-10 mt-auto bg-background" 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} {composer}
</div> </div>
</div> </div>
</div> </div>
) : ( ) : (
<div ref={contentRef} className="mx-auto flex min-h-full w-full max-w-[72rem] flex-col px-4"> <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-10 sm:py-12"> <div className="flex w-full flex-1 items-center justify-center py-6 sm:py-12">
<div className="relative w-full max-w-[58rem]"> <div className="relative flex w-full max-w-[58rem] flex-col items-center gap-5 sm:block">
<div className="absolute inset-x-0 bottom-[calc(100%+1.5rem)] flex justify-center"> <div className="flex justify-center sm:absolute sm:inset-x-0 sm:bottom-[calc(100%+1.5rem)]">
{emptyState} {emptyState}
</div> </div>
<div className="w-full">{composer}</div> <div className="w-full">{composer}</div>
@@ -106,7 +106,7 @@ export function WorkspaceProjectPicker({
if (nativeProjectPicker) { if (nativeProjectPicker) {
return ( 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 <button
type="button" type="button"
disabled={disabled || pickingFolder} disabled={disabled || pickingFolder}
@@ -114,7 +114,7 @@ export function WorkspaceProjectPicker({
title={currentProjectScope?.project_path} title={currentProjectScope?.project_path}
onClick={() => void pickNativeFolder()} onClick={() => void pickNativeFolder()}
className={cn( 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", "text-[12px] font-medium text-muted-foreground/90 transition-colors",
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55", "hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
currentProjectScope && "text-foreground/82", currentProjectScope && "text-foreground/82",
@@ -124,7 +124,7 @@ export function WorkspaceProjectPicker({
<span className="truncate">{projectLabel}</span> <span className="truncate">{projectLabel}</span>
</button> </button>
{pathError || error ? ( {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} {pathError ?? error}
</span> </span>
) : null} ) : null}
@@ -133,7 +133,7 @@ export function WorkspaceProjectPicker({
} }
return ( 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}> <DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<button <button
@@ -141,7 +141,7 @@ export function WorkspaceProjectPicker({
disabled={disabled} disabled={disabled}
aria-label={t("thread.composer.workspace.projectAria")} aria-label={t("thread.composer.workspace.projectAria")}
className={cn( 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", "text-[12px] font-medium text-muted-foreground/90 transition-colors",
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55", "hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
currentProjectScope && "text-foreground/82", currentProjectScope && "text-foreground/82",
@@ -254,7 +254,7 @@ export function WorkspaceAccessMenu({
variant="ghost" variant="ghost"
aria-label={t("thread.composer.workspace.accessAria")} aria-label={t("thread.composer.workspace.accessAria")}
className={cn( 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]", isHero ? "h-8 px-2.5 text-[12px]" : "h-9 px-3 text-[12.5px]",
isFull isFull
? "bg-transparent text-orange-600 hover:bg-orange-500/8 dark:text-orange-300 dark:hover:bg-orange-400/10" ? "bg-transparent text-orange-600 hover:bg-orange-500/8 dark:text-orange-300 dark:hover:bg-orange-400/10"
+8
View File
@@ -88,6 +88,7 @@
"interface": "Interface", "interface": "Interface",
"ai": "AI", "ai": "AI",
"system": "System", "system": "System",
"about": "About",
"status": "Status", "status": "Status",
"localPreferences": "Local preferences", "localPreferences": "Local preferences",
"presets": "Presets", "presets": "Presets",
@@ -253,6 +254,13 @@
"unavailable": "Unavailable", "unavailable": "Unavailable",
"noDescription": "No description available." "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": { "mcp": {
"allCategories": "All categories", "allCategories": "All categories",
"summary": "{{installed}} of {{total}} presets enabled", "summary": "{{installed}} of {{total}} presets enabled",
+8
View File
@@ -88,6 +88,7 @@
"interface": "Interfaz", "interface": "Interfaz",
"ai": "AI", "ai": "AI",
"system": "Sistema", "system": "Sistema",
"about": "Acerca de",
"status": "Estado", "status": "Estado",
"localPreferences": "Preferencias locales", "localPreferences": "Preferencias locales",
"presets": "Preajustes", "presets": "Preajustes",
@@ -393,6 +394,13 @@
"unavailable": "No disponible", "unavailable": "No disponible",
"noDescription": "Sin descripción 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": { "mcp": {
"allCategories": "Todas las categorías", "allCategories": "Todas las categorías",
"summary": "{{installed}} de {{total}} presets habilitados", "summary": "{{installed}} de {{total}} presets habilitados",
+8
View File
@@ -88,6 +88,7 @@
"interface": "Interface utilisateur", "interface": "Interface utilisateur",
"ai": "AI", "ai": "AI",
"system": "Système", "system": "Système",
"about": "À propos",
"status": "État", "status": "État",
"localPreferences": "Préférences locales", "localPreferences": "Préférences locales",
"presets": "Préréglages", "presets": "Préréglages",
@@ -393,6 +394,13 @@
"unavailable": "Indisponible", "unavailable": "Indisponible",
"noDescription": "Aucune description disponible." "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": { "mcp": {
"allCategories": "Toutes les catégories", "allCategories": "Toutes les catégories",
"summary": "{{installed}} presets activés sur {{total}}", "summary": "{{installed}} presets activés sur {{total}}",
+8
View File
@@ -88,6 +88,7 @@
"interface": "Antarmuka", "interface": "Antarmuka",
"ai": "AI", "ai": "AI",
"system": "Sistem", "system": "Sistem",
"about": "Tentang",
"status": "Status", "status": "Status",
"localPreferences": "Preferensi lokal", "localPreferences": "Preferensi lokal",
"presets": "Preset", "presets": "Preset",
@@ -393,6 +394,13 @@
"unavailable": "Tidak tersedia", "unavailable": "Tidak tersedia",
"noDescription": "Tidak ada deskripsi." "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": { "mcp": {
"allCategories": "Semua kategori", "allCategories": "Semua kategori",
"summary": "{{installed}} dari {{total}} preset diaktifkan", "summary": "{{installed}} dari {{total}} preset diaktifkan",
+8
View File
@@ -88,6 +88,7 @@
"interface": "インターフェース", "interface": "インターフェース",
"ai": "AI", "ai": "AI",
"system": "システム", "system": "システム",
"about": "情報",
"status": "状態", "status": "状態",
"localPreferences": "ローカル設定", "localPreferences": "ローカル設定",
"presets": "プリセット", "presets": "プリセット",
@@ -393,6 +394,13 @@
"unavailable": "利用不可", "unavailable": "利用不可",
"noDescription": "説明はありません。" "noDescription": "説明はありません。"
}, },
"about": {
"version": "バージョン",
"checking": "確認中...",
"checkForUpdates": "アップデートを確認",
"upToDate": "最新の状態です",
"updateAvailable": "アップデートがあります v{{version}}"
},
"mcp": { "mcp": {
"allCategories": "すべてのカテゴリ", "allCategories": "すべてのカテゴリ",
"summary": "{{total}} 個中 {{installed}} 個のプリセットが有効", "summary": "{{total}} 個中 {{installed}} 個のプリセットが有効",
+8
View File
@@ -88,6 +88,7 @@
"interface": "인터페이스", "interface": "인터페이스",
"ai": "AI", "ai": "AI",
"system": "시스템", "system": "시스템",
"about": "정보",
"status": "상태", "status": "상태",
"localPreferences": "로컬 환경설정", "localPreferences": "로컬 환경설정",
"presets": "프리셋", "presets": "프리셋",
@@ -393,6 +394,13 @@
"unavailable": "사용 불가", "unavailable": "사용 불가",
"noDescription": "설명이 없습니다." "noDescription": "설명이 없습니다."
}, },
"about": {
"version": "버전",
"checking": "확인 중...",
"checkForUpdates": "업데이트 확인",
"upToDate": "최신 버전입니다",
"updateAvailable": "업데이트 사용 가능 v{{version}}"
},
"mcp": { "mcp": {
"allCategories": "모든 카테고리", "allCategories": "모든 카테고리",
"summary": "프리셋 {{total}}개 중 {{installed}}개 활성화됨", "summary": "프리셋 {{total}}개 중 {{installed}}개 활성화됨",
+8
View File
@@ -88,6 +88,7 @@
"interface": "Giao diện", "interface": "Giao diện",
"ai": "AI", "ai": "AI",
"system": "Hệ thống", "system": "Hệ thống",
"about": "Giới thiệu",
"status": "Trạng thái", "status": "Trạng thái",
"localPreferences": "Tùy chọn cục bộ", "localPreferences": "Tùy chọn cục bộ",
"presets": "Preset", "presets": "Preset",
@@ -393,6 +394,13 @@
"unavailable": "Không khả dụng", "unavailable": "Không khả dụng",
"noDescription": "Không có mô tả." "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": { "mcp": {
"allCategories": "Tất cả danh mục", "allCategories": "Tất cả danh mục",
"summary": "Đã bật {{installed}} / {{total}} preset", "summary": "Đã bật {{installed}} / {{total}} preset",
+8
View File
@@ -88,6 +88,7 @@
"interface": "界面", "interface": "界面",
"ai": "AI", "ai": "AI",
"system": "系统", "system": "系统",
"about": "关于",
"status": "状态", "status": "状态",
"localPreferences": "本地偏好", "localPreferences": "本地偏好",
"presets": "预设", "presets": "预设",
@@ -253,6 +254,13 @@
"unavailable": "不可用", "unavailable": "不可用",
"noDescription": "暂无描述。" "noDescription": "暂无描述。"
}, },
"about": {
"version": "版本",
"checking": "正在检查...",
"checkForUpdates": "检查更新",
"upToDate": "已是最新版本",
"updateAvailable": "有可用更新 v{{version}}"
},
"mcp": { "mcp": {
"allCategories": "全部分类", "allCategories": "全部分类",
"summary": "已启用 {{installed}} / {{total}} 个预设", "summary": "已启用 {{installed}} / {{total}} 个预设",
+8
View File
@@ -88,6 +88,7 @@
"interface": "介面", "interface": "介面",
"ai": "AI", "ai": "AI",
"system": "系統", "system": "系統",
"about": "關於",
"status": "狀態", "status": "狀態",
"localPreferences": "本機偏好", "localPreferences": "本機偏好",
"presets": "預設", "presets": "預設",
@@ -393,6 +394,13 @@
"unavailable": "不可用", "unavailable": "不可用",
"noDescription": "暫無描述。" "noDescription": "暫無描述。"
}, },
"about": {
"version": "版本",
"checking": "正在檢查...",
"checkForUpdates": "檢查更新",
"upToDate": "已是最新版本",
"updateAvailable": "有可用更新 v{{version}}"
},
"mcp": { "mcp": {
"allCategories": "全部分類", "allCategories": "全部分類",
"summary": "已啟用 {{installed}} / {{total}} 個預設", "summary": "已啟用 {{installed}} / {{total}} 個預設",
+9
View File
@@ -66,6 +66,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.sections.webuiSafety", "settings.sections.webuiSafety",
"settings.sections.capabilities", "settings.sections.capabilities",
"settings.sections.apps", "settings.sections.apps",
"settings.sections.about",
"settings.rows.theme", "settings.rows.theme",
"settings.rows.language", "settings.rows.language",
"settings.rows.density", "settings.rows.density",
@@ -101,6 +102,10 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.status.upToDate", "settings.status.upToDate",
"settings.actions.save", "settings.actions.save",
"settings.actions.saving", "settings.actions.saving",
"settings.about.checking",
"settings.about.checkForUpdates",
"settings.about.upToDate",
"settings.about.updateAvailable",
]; ];
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value); return !!value && typeof value === "object" && !Array.isArray(value);
@@ -244,6 +249,7 @@ describe("webui i18n", () => {
for (const key of SETTINGS_NAV_KEYS) { for (const key of SETTINGS_NAV_KEYS) {
expect(common.settings.nav[key as keyof typeof common.settings.nav]).toBeTruthy(); 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.rows.theme).toBeTruthy();
expect(common.settings.status.loading).toBeTruthy(); expect(common.settings.status.loading).toBeTruthy();
expect(common.settings.actions.save).toBeTruthy(); expect(common.settings.actions.save).toBeTruthy();
@@ -255,6 +261,9 @@ describe("webui i18n", () => {
expect(common.settings.byok.showApiKey).toBeTruthy(); expect(common.settings.byok.showApiKey).toBeTruthy();
expect(common.settings.byok.hideApiKey).toBeTruthy(); expect(common.settings.byok.hideApiKey).toBeTruthy();
expect(common.settings.byok.configuredKeyHint).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}}");
} }
}); });
+49
View File
@@ -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(() => { afterEach(() => {
vi.restoreAllMocks(); vi.restoreAllMocks();
vi.unstubAllGlobals(); 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", () => { it("dismisses the slash command palette on outside click", () => {
render( render(
<div> <div>
+213 -43
View File
@@ -29,6 +29,59 @@ interface ResizeObserverInstance {
disconnect: ReturnType<typeof vi.fn>; 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[] { function makeLongMessages(count: number): UIMessage[] {
return Array.from({ length: count }, (_, index) => ({ return Array.from({ length: count }, (_, index) => ({
id: `m${index}`, id: `m${index}`,
@@ -57,6 +110,21 @@ function ViewportWithPromptNavigator({ messages }: { messages: UIMessage[] }) {
} }
describe("ThreadViewport", () => { 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", () => { it("keeps the scroll-to-bottom button above a growing composer", () => {
const originalResizeObserver = globalThis.ResizeObserver; const originalResizeObserver = globalThis.ResizeObserver;
const resizeObservers: ResizeObserverInstance[] = []; 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", () => { it("hides the scroll-to-bottom button when disabled for the welcome view", () => {
const { container } = render( const { container } = render(
<ThreadViewport <ThreadViewport
@@ -455,11 +641,7 @@ describe("ThreadViewport", () => {
}); });
it("resets to the bottom when opening a different conversation", async () => { it("resets to the bottom when opening a different conversation", async () => {
const scrollIntoView = vi.fn(); const scrollTo = vi.fn();
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
HTMLElement.prototype.scrollIntoView = scrollIntoView;
try {
const { container, rerender } = render( const { container, rerender } = render(
<ThreadViewport <ThreadViewport
messages={messages} messages={messages}
@@ -472,12 +654,13 @@ describe("ThreadViewport", () => {
Object.defineProperties(scroller, { Object.defineProperties(scroller, {
scrollHeight: { configurable: true, value: 2400 }, scrollHeight: { configurable: true, value: 2400 },
clientHeight: { configurable: true, value: 600 }, clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, value: 0 }, scrollTop: { configurable: true, writable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
}); });
act(() => { act(() => {
scroller.dispatchEvent(new Event("scroll")); scroller.dispatchEvent(new Event("scroll"));
}); });
scrollIntoView.mockClear(); scrollTo.mockClear();
rerender( rerender(
<ThreadViewport <ThreadViewport
@@ -489,22 +672,15 @@ describe("ThreadViewport", () => {
); );
await waitFor(() => await waitFor(() =>
expect(scrollIntoView).toHaveBeenCalledWith({ expect(scrollTo).toHaveBeenCalledWith({
block: "end", top: 1800,
behavior: "auto", behavior: "auto",
}), }),
); );
} finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
}
}); });
it("waits for hydrated messages before fulfilling open-chat bottom scroll", async () => { it("waits for hydrated messages before fulfilling open-chat bottom scroll", async () => {
const scrollIntoView = vi.fn(); const scrollTo = vi.fn();
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
HTMLElement.prototype.scrollIntoView = scrollIntoView;
try {
const { container, rerender } = render( const { container, rerender } = render(
<ThreadViewport <ThreadViewport
messages={emptyMessages} messages={emptyMessages}
@@ -514,11 +690,13 @@ describe("ThreadViewport", () => {
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperty(scroller, "scrollHeight", { Object.defineProperties(scroller, {
configurable: true, scrollHeight: { configurable: true, value: 0 },
value: 0, clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
}); });
scrollIntoView.mockClear(); scrollTo.mockClear();
rerender( rerender(
<ThreadViewport <ThreadViewport
@@ -528,8 +706,8 @@ describe("ThreadViewport", () => {
conversationKey="chat-a" conversationKey="chat-a"
/>, />,
); );
expect(scrollIntoView).toHaveBeenCalledWith({ expect(scrollTo).toHaveBeenCalledWith({
block: "end", top: 0,
behavior: "auto", behavior: "auto",
}); });
@@ -537,7 +715,7 @@ describe("ThreadViewport", () => {
configurable: true, configurable: true,
value: 2400, value: 2400,
}); });
scrollIntoView.mockClear(); scrollTo.mockClear();
rerender( rerender(
<ThreadViewport <ThreadViewport
@@ -549,22 +727,15 @@ describe("ThreadViewport", () => {
); );
await waitFor(() => await waitFor(() =>
expect(scrollIntoView).toHaveBeenCalledWith({ expect(scrollTo).toHaveBeenCalledWith({
block: "end", top: 1800,
behavior: "auto", behavior: "auto",
}), }),
); );
} finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
}
}); });
it("scrolls to the bottom when explicitly signalled after send", async () => { it("scrolls to the bottom when explicitly signalled after send", async () => {
const scrollIntoView = vi.fn(); const scrollTo = vi.fn();
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
HTMLElement.prototype.scrollIntoView = scrollIntoView;
try {
const { container, rerender } = render( const { container, rerender } = render(
<ThreadViewport <ThreadViewport
messages={messages} messages={messages}
@@ -574,11 +745,13 @@ describe("ThreadViewport", () => {
/>, />,
); );
const scroller = container.firstElementChild?.firstElementChild as HTMLElement; const scroller = container.firstElementChild?.firstElementChild as HTMLElement;
Object.defineProperty(scroller, "scrollHeight", { Object.defineProperties(scroller, {
configurable: true, scrollHeight: { configurable: true, value: 2400 },
value: 2400, clientHeight: { configurable: true, value: 600 },
scrollTop: { configurable: true, writable: true, value: 0 },
scrollTo: { configurable: true, value: scrollTo },
}); });
scrollIntoView.mockClear(); scrollTo.mockClear();
rerender( rerender(
<ThreadViewport <ThreadViewport
@@ -590,13 +763,10 @@ describe("ThreadViewport", () => {
); );
await waitFor(() => await waitFor(() =>
expect(scrollIntoView).toHaveBeenCalledWith({ expect(scrollTo).toHaveBeenCalledWith({
block: "end", top: 1800,
behavior: "auto", behavior: "auto",
}), }),
); );
} finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
}
}); });
}); });