feat(webui): polish chat layout and titles

Align the WebUI sidebar and chat chrome with the updated design, and generate WebUI session titles asynchronously without blocking turns.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xubin Ren
2026-05-06 22:20:35 +08:00
committed by Xubin Ren
co-authored by Cursor
parent d8fd4c80bf
commit 790a03ec28
33 changed files with 1270 additions and 312 deletions
+43
View File
@@ -1,5 +1,6 @@
"""Tests for structured tool-event progress metadata emitted by AgentLoop."""
import asyncio
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock
@@ -291,6 +292,48 @@ class TestToolEventProgress:
assert (outbound[-1].metadata or {}).get("_turn_end") is True
assert outbound[-1].chat_id == "chat1"
@pytest.mark.asyncio
async def test_webui_title_generation_runs_after_turn_end(self, tmp_path: Path) -> None:
bus = MessageBus()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
title_started = asyncio.Event()
release_title = asyncio.Event()
calls = 0
async def chat_with_retry(*_args: object, **_kwargs: object) -> LLMResponse:
nonlocal calls
calls += 1
if calls == 1:
return LLMResponse(content="Done", tool_calls=[])
title_started.set()
await release_title.wait()
return LLMResponse(content="Generated title", tool_calls=[])
provider.chat_with_retry = AsyncMock(side_effect=chat_with_retry)
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
loop.tools.get_definitions = MagicMock(return_value=[])
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
await asyncio.wait_for(loop._dispatch(InboundMessage(
channel="websocket",
sender_id="u1",
chat_id="chat1",
content="say hello",
metadata={"webui": True},
)), timeout=0.5)
outbound = [await bus.consume_outbound(), await bus.consume_outbound()]
assert outbound[0].content == "Done"
assert (outbound[1].metadata or {}).get("_turn_end") is True
await asyncio.wait_for(title_started.wait(), timeout=0.5)
release_title.set()
session_updated = await asyncio.wait_for(bus.consume_outbound(), timeout=0.5)
assert (session_updated.metadata or {}).get("_session_updated") is True
assert provider.chat_with_retry.await_count == 2
@pytest.mark.asyncio
async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None:
bus = MessageBus()
+53
View File
@@ -8,7 +8,13 @@ from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
from nanobot.session.manager import Session
from nanobot.utils.webui_titles import (
WEBUI_SESSION_METADATA_KEY,
WEBUI_TITLE_METADATA_KEY,
maybe_generate_webui_title,
)
def _mk_loop() -> AgentLoop:
@@ -22,9 +28,56 @@ def _mk_loop() -> AgentLoop:
def _make_full_loop(tmp_path: Path) -> AgentLoop:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Test title"))
return AgentLoop(bus=MessageBus(), provider=provider, workspace=tmp_path, model="test-model")
@pytest.mark.asyncio
async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
loop.provider.chat_with_retry = AsyncMock(
return_value=LLMResponse(content='"优化 WebUI 侧边栏。"', finish_reason="stop")
)
session = loop.sessions.get_or_create("websocket:chat-title")
session.metadata[WEBUI_SESSION_METADATA_KEY] = True
session.add_message("user", "帮我优化一下 webui 的 sidebar")
session.add_message("assistant", "可以,我会先调整布局和视觉层级。")
loop.sessions.save(session)
generated = await maybe_generate_webui_title(
sessions=loop.sessions,
session_key="websocket:chat-title",
provider=loop.provider,
model=loop.model,
)
assert generated is True
assert session.metadata[WEBUI_TITLE_METADATA_KEY] == "优化 WebUI 侧边栏"
loop.provider.chat_with_retry.assert_awaited_once()
@pytest.mark.asyncio
async def test_generate_webui_title_skips_plain_websocket_sessions(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
loop.provider.chat_with_retry = AsyncMock(
return_value=LLMResponse(content="Plain websocket title", finish_reason="stop")
)
session = loop.sessions.get_or_create("websocket:custom-client")
session.add_message("user", "hello from a custom websocket client")
loop.sessions.save(session)
generated = await maybe_generate_webui_title(
sessions=loop.sessions,
session_key="websocket:custom-client",
provider=loop.provider,
model=loop.model,
)
assert generated is False
assert WEBUI_TITLE_METADATA_KEY not in session.metadata
loop.provider.chat_with_retry.assert_not_awaited()
def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None:
loop = _mk_loop()
session = Session(key="test:runtime-only")
+13 -1
View File
@@ -1,4 +1,4 @@
from nanobot.session.manager import Session
from nanobot.session.manager import Session, SessionManager
def _assert_no_orphans(history: list[dict]) -> None:
@@ -31,6 +31,18 @@ def _tool_turn(prefix: str, idx: int) -> list[dict]:
]
def test_list_sessions_includes_metadata_title(tmp_path):
manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:chat-title")
session.metadata["title"] = "自动生成标题"
manager.save(session)
rows = manager.list_sessions()
assert rows[0]["key"] == "websocket:chat-title"
assert rows[0]["title"] == "自动生成标题"
# --- Original regression test (from PR 2075) ---
def test_get_history_drops_orphan_tool_results_when_window_cuts_tool_calls():