From 790a03ec2877223e39d88ad088265abe1d58a898 Mon Sep 17 00:00:00 2001 From: Xubin Ren Date: Wed, 6 May 2026 14:15:36 +0000 Subject: [PATCH] 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 --- nanobot/agent/loop.py | 21 ++ nanobot/channels/websocket.py | 18 +- nanobot/session/manager.py | 8 + nanobot/utils/webui_titles.py | 138 +++++++++++++ tests/agent/test_loop_progress.py | 43 ++++ tests/agent/test_loop_save_turn.py | 53 +++++ tests/agent/test_session_manager_history.py | 14 +- tests/channels/test_websocket_channel.py | 53 +++++ webui/src/App.tsx | 60 +++--- webui/src/components/ChatList.tsx | 163 +++++++++------ webui/src/components/ChatPane.tsx | 16 +- webui/src/components/ConnectionBadge.tsx | 14 +- webui/src/components/MessageBubble.tsx | 51 ++++- webui/src/components/Sidebar.tsx | 128 ++++++------ .../src/components/thread/ThreadComposer.tsx | 38 ++-- webui/src/components/thread/ThreadHeader.tsx | 94 +++++++-- webui/src/components/thread/ThreadShell.tsx | 189 ++++++++++++------ .../src/components/thread/ThreadViewport.tsx | 6 +- webui/src/globals.css | 8 +- webui/src/hooks/useNanobotStream.ts | 9 +- webui/src/hooks/useSessions.ts | 3 +- webui/src/i18n/locales/en/common.json | 60 +++++- webui/src/i18n/locales/zh-CN/common.json | 60 +++++- webui/src/lib/api.ts | 2 + webui/src/lib/nanobot-client.ts | 4 +- webui/src/lib/types.ts | 5 + webui/src/tests/api.test.ts | 26 ++- webui/src/tests/app-layout.test.tsx | 112 ++++++++++- webui/src/tests/message-bubble.test.tsx | 42 +++- webui/src/tests/nanobot-client.test.ts | 4 +- webui/src/tests/thread-composer.test.tsx | 31 ++- webui/src/tests/thread-shell.test.tsx | 88 +++++++- webui/src/tests/useNanobotStream.test.tsx | 21 +- 33 files changed, 1270 insertions(+), 312 deletions(-) create mode 100644 nanobot/utils/webui_titles.py diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 784c4da13..d1952312b 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -55,6 +55,7 @@ from nanobot.utils.progress_events import ( on_progress_accepts_tool_events, ) from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE +from nanobot.utils.webui_titles import mark_webui_session, maybe_generate_webui_title_after_turn if TYPE_CHECKING: from nanobot.config.schema import ChannelsConfig, ExecToolConfig, ToolsConfig, WebToolsConfig @@ -814,6 +815,25 @@ class AgentLoop: channel=msg.channel, chat_id=msg.chat_id, content="", metadata={**msg.metadata, "_turn_end": True}, )) + if msg.metadata.get("webui") is True: + async def _generate_title_and_notify() -> None: + generated = await maybe_generate_webui_title_after_turn( + channel=msg.channel, + metadata=msg.metadata, + sessions=self.sessions, + session_key=session_key, + provider=self.provider, + model=self.model, + ) + if generated: + await self.bus.publish_outbound(OutboundMessage( + channel=msg.channel, + chat_id=msg.chat_id, + content="", + metadata={**msg.metadata, "_session_updated": True}, + )) + + self._schedule_background(_generate_title_and_notify()) except asyncio.CancelledError: logger.info("Task cancelled for session {}", session_key) # Preserve partial context from the interrupted turn so @@ -1003,6 +1023,7 @@ class AgentLoop: key = session_key or msg.session_key session = self.sessions.get_or_create(key) + mark_webui_session(session, msg.metadata) if self._restore_runtime_checkpoint(session): self.sessions.save(session) if self._restore_pending_user_turn(session): diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 0f60c63a8..62e67a5b7 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -1184,12 +1184,15 @@ class WebSocketChannel(BaseChannel): # Auto-attach on first use so clients can one-shot without a separate attach. self._attach(connection, cid) + metadata: dict[str, Any] = {"remote": getattr(connection, "remote_address", None)} + if envelope.get("webui") is True: + metadata["webui"] = True await self._handle_message( sender_id=client_id, chat_id=cid, content=content, media=media_paths or None, - metadata={"remote": getattr(connection, "remote_address", None)}, + metadata=metadata, ) return await self._send_event(connection, "error", detail=f"unknown type: {t!r}") @@ -1233,6 +1236,9 @@ class WebSocketChannel(BaseChannel): if msg.metadata.get("_turn_end"): await self.send_turn_end(msg.chat_id) return + if msg.metadata.get("_session_updated"): + await self.send_session_updated(msg.chat_id) + return text = msg.content if msg.buttons: text = _append_buttons_as_text(text, msg.buttons) @@ -1299,3 +1305,13 @@ class WebSocketChannel(BaseChannel): raw = json.dumps(body, ensure_ascii=False) for connection in conns: await self._safe_send_to(connection, raw, label=" turn_end ") + + async def send_session_updated(self, chat_id: str) -> None: + """Notify clients that session metadata changed outside the main turn.""" + conns = list(self._subs.get(chat_id, ())) + if not conns: + return + body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id} + raw = json.dumps(body, ensure_ascii=False) + for connection in conns: + await self._safe_send_to(connection, raw, label=" session_updated ") diff --git a/nanobot/session/manager.py b/nanobot/session/manager.py index 06c7317d0..859d2cca8 100644 --- a/nanobot/session/manager.py +++ b/nanobot/session/manager.py @@ -547,10 +547,13 @@ class SessionManager: data = json.loads(first_line) if data.get("_type") == "metadata": key = data.get("key") or path.stem.replace("_", ":", 1) + metadata = data.get("metadata", {}) + title = metadata.get("title") if isinstance(metadata, dict) else None sessions.append({ "key": key, "created_at": data.get("created_at"), "updated_at": data.get("updated_at"), + "title": title if isinstance(title, str) else "", "path": str(path) }) except Exception: @@ -560,6 +563,11 @@ class SessionManager: "key": repaired.key, "created_at": repaired.created_at.isoformat(), "updated_at": repaired.updated_at.isoformat(), + "title": ( + repaired.metadata.get("title") + if isinstance(repaired.metadata.get("title"), str) + else "" + ), "path": str(path) }) continue diff --git a/nanobot/utils/webui_titles.py b/nanobot/utils/webui_titles.py new file mode 100644 index 000000000..2d363f926 --- /dev/null +++ b/nanobot/utils/webui_titles.py @@ -0,0 +1,138 @@ +"""Helpers for WebUI chat title generation.""" + +from __future__ import annotations + +import re +from typing import Any + +from loguru import logger + +from nanobot.providers.base import LLMProvider +from nanobot.session.manager import Session, SessionManager +from nanobot.utils.helpers import truncate_text + +WEBUI_SESSION_METADATA_KEY = "webui" +WEBUI_TITLE_METADATA_KEY = "title" +WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited" +TITLE_MAX_CHARS = 60 + + +def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool: + """Persist a WebUI marker only when the inbound websocket frame opted in.""" + if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True: + return False + session.metadata[WEBUI_SESSION_METADATA_KEY] = True + return True + + +def clean_generated_title(raw: str | None) -> str: + text = (raw or "").strip() + if not text: + return "" + text = re.sub(r"^\s*(title|标题)\s*[::]\s*", "", text, flags=re.IGNORECASE) + text = text.strip().strip("\"'`“”‘’") + text = re.sub(r"\s+", " ", text).strip() + text = text.rstrip("。.!!??,,;;:") + if len(text) > TITLE_MAX_CHARS: + text = text[: TITLE_MAX_CHARS - 1].rstrip() + "…" + return text + + +def _title_inputs(session: Session) -> tuple[str, str]: + user_text = "" + assistant_text = "" + for message in session.messages: + role = message.get("role") + content = message.get("content") + if not isinstance(content, str) or not content.strip(): + continue + if role == "user" and not user_text: + user_text = content.strip() + elif role == "assistant" and not assistant_text: + assistant_text = content.strip() + if user_text and assistant_text: + break + return user_text, assistant_text + + +async def maybe_generate_webui_title( + *, + sessions: SessionManager, + session_key: str, + provider: LLMProvider, + model: str, +) -> bool: + """Generate and persist a short title for WebUI-owned sessions only.""" + session = sessions.get_or_create(session_key) + if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True: + return False + if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True: + return False + current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY) + if isinstance(current_title, str) and current_title.strip(): + return False + + user_text, assistant_text = _title_inputs(session) + if not user_text: + return False + + prompt = ( + "Generate a concise title for this chat.\n" + "Rules:\n" + "- Use the same language as the user when practical.\n" + "- 3 to 8 words.\n" + "- No quotes.\n" + "- No punctuation at the end.\n" + "- Return only the title.\n\n" + f"User: {truncate_text(user_text, 1_000)}" + ) + if assistant_text: + prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}" + + try: + response = await provider.chat_with_retry( + [ + { + "role": "system", + "content": ( + "You write short, neutral chat titles. " + "Return only the title text." + ), + }, + {"role": "user", "content": prompt}, + ], + tools=None, + model=model, + max_tokens=32, + temperature=0.2, + retry_mode="standard", + ) + except Exception: + logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True) + return False + + title = clean_generated_title(response.content) + if not title or title.lower().startswith("error"): + return False + session.metadata[WEBUI_TITLE_METADATA_KEY] = title + sessions.save(session) + return True + + +async def maybe_generate_webui_title_after_turn( + *, + channel: str, + metadata: dict[str, Any], + sessions: SessionManager, + session_key: str, + provider: LLMProvider, + model: str, +) -> bool: + if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True: + return False + return await maybe_generate_webui_title( + sessions=sessions, + session_key=session_key, + provider=provider, + model=model, + ) diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py index 47a63ba02..ee3f1e3db 100644 --- a/tests/agent/test_loop_progress.py +++ b/tests/agent/test_loop_progress.py @@ -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() diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py index c3dd90af2..36b133999 100644 --- a/tests/agent/test_loop_save_turn.py +++ b/tests/agent/test_loop_save_turn.py @@ -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") diff --git a/tests/agent/test_session_manager_history.py b/tests/agent/test_session_manager_history.py index b80c774a1..75bc7713d 100644 --- a/tests/agent/test_session_manager_history.py +++ b/tests/agent/test_session_manager_history.py @@ -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(): diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index db61fc285..f20095388 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -167,6 +167,40 @@ def test_issue_route_secret_matches_empty_secret() -> None: assert _issue_route_secret_matches(Headers([("Authorization", "Bearer anything")]), "") is True +@pytest.mark.asyncio +async def test_webui_message_envelope_marks_inbound_metadata(bus: MagicMock) -> None: + channel = _ch(bus) + conn = MagicMock() + conn.remote_address = ("127.0.0.1", 50123) + + await channel._dispatch_envelope( + conn, + "webui-client", + {"type": "message", "chat_id": "chat-1", "content": "hello", "webui": True}, + ) + + msg = bus.publish_inbound.await_args.args[0] + assert msg.channel == "websocket" + assert msg.chat_id == "chat-1" + assert msg.metadata["webui"] is True + assert msg.metadata["_wants_stream"] is True + + +@pytest.mark.asyncio +async def test_plain_websocket_message_does_not_mark_webui(bus: MagicMock) -> None: + channel = _ch(bus) + conn = MagicMock() + + await channel._dispatch_envelope( + conn, + "custom-client", + {"type": "message", "chat_id": "chat-1", "content": "hello"}, + ) + + msg = bus.publish_inbound.await_args.args[0] + assert "webui" not in msg.metadata + + @pytest.mark.asyncio async def test_send_delivers_json_message_with_media_and_reply() -> None: bus = MagicMock() @@ -306,6 +340,25 @@ async def test_send_turn_end_emits_turn_end_event() -> None: assert body == {"event": "turn_end", "chat_id": "chat-1"} +@pytest.mark.asyncio +async def test_send_session_updated_emits_session_updated_event() -> None: + bus = MagicMock() + channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus) + mock_ws = AsyncMock() + channel._attach(mock_ws, "chat-1") + + await channel.send(OutboundMessage( + channel="websocket", + chat_id="chat-1", + content="", + metadata={"_session_updated": True}, + )) + + mock_ws.send.assert_awaited_once() + body = json.loads(mock_ws.send.await_args.args[0]) + assert body == {"event": "session_updated", "chat_id": "chat-1"} + + @pytest.mark.asyncio async def test_send_non_connection_closed_exception_is_raised() -> None: bus = MagicMock() diff --git a/webui/src/App.tsx b/webui/src/App.tsx index c6ad6f067..0fbb3f54f 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -25,7 +25,7 @@ type BootState = }; const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar"; -const SIDEBAR_WIDTH = 279; +const SIDEBAR_WIDTH = 272; type ShellView = "chat" | "settings"; function readSidebarOpen(): boolean { @@ -99,13 +99,6 @@ export default function App() { return (
-
@@ -121,13 +114,6 @@ export default function App() { return (
-

{t("app.error.title")}

{state.message}

@@ -213,7 +199,7 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | } }, []); - const onNewChat = useCallback(async () => { + const onCreateChat = useCallback(async () => { try { const chatId = await createChat(); setActiveKey(`websocket:${chatId}`); @@ -226,6 +212,12 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | } }, [createChat]); + const onNewChat = useCallback(() => { + setActiveKey(null); + setView("chat"); + setMobileSidebarOpen(false); + }, []); + const onSelectChat = useCallback( (key: string) => { setActiveKey(key); @@ -235,6 +227,15 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | [], ); + const onOpenSettings = useCallback(() => { + setView("settings"); + setMobileSidebarOpen(false); + }, []); + + const onTurnEnd = useCallback(() => { + void refresh(); + }, [refresh]); + const onConfirmDelete = useCallback(async () => { if (!pendingDelete) return; const key = pendingDelete.key; @@ -254,7 +255,8 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | }, [pendingDelete, deleteChat, activeKey, sessions]); const headerTitle = activeSession - ? activeSession.preview || + ? activeSession.title || + activeSession.preview || t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) }) : t("app.brand"); @@ -268,20 +270,10 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | sessions, activeKey, loading, - theme, - onToggleTheme: toggle, - onNewChat: () => { - void onNewChat(); - }, + onNewChat, onSelect: onSelectChat, - onRefresh: () => void refresh(), onRequestDelete: (key: string, label: string) => setPendingDelete({ key, label }), - activeView: view, - onOpenSettings: () => { - setView("settings" as const); - setMobileSidebarOpen(false); - }, }; return ( @@ -296,10 +288,11 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | >

@@ -312,7 +305,8 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | @@ -331,8 +325,12 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string | session={activeSession} title={headerTitle} onToggleSidebar={toggleSidebar} - onGoHome={() => setActiveKey(null)} onNewChat={onNewChat} + onCreateChat={onCreateChat} + onTurnEnd={onTurnEnd} + theme={theme} + onToggleTheme={toggle} + onOpenSettings={onOpenSettings} hideSidebarToggleOnDesktop={desktopSidebarOpen} /> )} diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index f77f7c1b2..ce7bb17e0 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -8,7 +8,6 @@ import { DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; import { ScrollArea } from "@/components/ui/scroll-area"; -import { relativeTime } from "@/lib/format"; import { cn } from "@/lib/utils"; import type { ChatSummary } from "@/lib/types"; @@ -18,10 +17,11 @@ interface ChatListProps { onSelect: (key: string) => void; onRequestDelete: (key: string, label: string) => void; loading?: boolean; + emptyLabel?: string; } function titleFor(s: ChatSummary, fallbackTitle: string): string { - const p = s.preview?.trim(); + const p = (s.title || s.preview)?.trim(); if (p) return p.length > 48 ? `${p.slice(0, 45)}…` : p; return fallbackTitle; } @@ -32,6 +32,7 @@ export function ChatList({ onSelect, onRequestDelete, loading, + emptyLabel, }: ChatListProps) { const { t } = useTranslation(); if (loading && sessions.length === 0) { @@ -44,73 +45,111 @@ export function ChatList({ if (sessions.length === 0) { return ( -
- {t("chat.noSessions")} +
+ {emptyLabel ?? t("chat.noSessions")}
); } + const groups = groupSessions(sessions, { + today: t("chat.groups.today"), + yesterday: t("chat.groups.yesterday"), + earlier: t("chat.groups.earlier"), + }); + return ( -
    - {sessions.map((s) => { - const active = s.key === activeKey; - const title = titleFor( - s, - t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }), - ); - return ( -
  • -
    - - - - - - event.preventDefault()} - > - { - window.setTimeout(() => onRequestDelete(s.key, title), 0); - }} - className="text-destructive focus:text-destructive" +
    + {groups.map((group) => ( +
    +
    + {group.label} +
    +
      + {group.sessions.map((s) => { + const active = s.key === activeKey; + const title = titleFor( + s, + t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }), + ); + return ( +
    • +
      - - {t("chat.delete")} - - - -
      -
    • - ); - })} -
    + + + + + + event.preventDefault()} + > + { + window.setTimeout(() => onRequestDelete(s.key, title), 0); + }} + className="text-destructive focus:text-destructive" + > + + {t("chat.delete")} + + + +
    +
  • + ); + })} +
+ + ))} +
); } + +function groupSessions( + sessions: ChatSummary[], + labels: { today: string; yesterday: string; earlier: string }, +): Array<{ label: string; sessions: ChatSummary[] }> { + const now = new Date(); + const startOfToday = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); + const startOfYesterday = startOfToday - 24 * 60 * 60 * 1000; + const buckets = new Map(); + + for (const session of sessions) { + const timestamp = Date.parse(session.updatedAt ?? session.createdAt ?? ""); + const label = Number.isFinite(timestamp) && timestamp >= startOfToday + ? labels.today + : Number.isFinite(timestamp) && timestamp >= startOfYesterday + ? labels.yesterday + : labels.earlier; + const bucket = buckets.get(label) ?? []; + bucket.push(session); + buckets.set(label, bucket); + } + + return [labels.today, labels.yesterday, labels.earlier] + .map((label) => ({ label, sessions: buckets.get(label) ?? [] })) + .filter((group) => group.sessions.length > 0); +} diff --git a/webui/src/components/ChatPane.tsx b/webui/src/components/ChatPane.tsx index 779d3695a..43fe64914 100644 --- a/webui/src/components/ChatPane.tsx +++ b/webui/src/components/ChatPane.tsx @@ -79,20 +79,8 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) {
- - - nanobot -

- What's on your mind? + What can I do for you?

Your conversations are persisted locally under the nanobot @@ -105,7 +93,7 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) { disabled={booting} onSend={handleWelcomeSend} placeholder={ - booting ? "Opening a new chat…" : "Type your message…" + booting ? "Opening a new chat…" : "Ask anything..." } />

diff --git a/webui/src/components/ConnectionBadge.tsx b/webui/src/components/ConnectionBadge.tsx index 354be976f..7616ddbe5 100644 --- a/webui/src/components/ConnectionBadge.tsx +++ b/webui/src/components/ConnectionBadge.tsx @@ -6,21 +6,21 @@ import { useClient } from "@/providers/ClientProvider"; import type { ConnectionStatus } from "@/lib/types"; const COPY: Record = { - idle: { color: "bg-card/40 text-muted-foreground" }, + idle: { color: "text-muted-foreground" }, connecting: { - color: "bg-amber-500/10 text-amber-700 dark:text-amber-300", + color: "text-amber-700 dark:text-amber-300", }, open: { - color: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400", + color: "text-emerald-700 dark:text-emerald-400", }, reconnecting: { - color: "bg-amber-500/10 text-amber-700 dark:text-amber-300", + color: "text-amber-700 dark:text-amber-300", }, closed: { - color: "bg-card/40 text-muted-foreground", + color: "text-muted-foreground", }, error: { - color: "bg-destructive/10 text-destructive", + color: "text-destructive", }, }; @@ -39,7 +39,7 @@ export function ConnectionBadge() { return ( (null); const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300"; + useEffect(() => { + return () => { + if (copyResetRef.current !== null) { + window.clearTimeout(copyResetRef.current); + } + }; + }, []); + + const onCopyAssistantReply = useCallback(() => { + if (!navigator.clipboard) return; + void navigator.clipboard.writeText(message.content).then(() => { + setCopied(true); + if (copyResetRef.current !== null) { + window.clearTimeout(copyResetRef.current); + } + copyResetRef.current = window.setTimeout(() => { + setCopied(false); + copyResetRef.current = null; + }, 1_500); + }); + }, [message.content]); + if (message.kind === "trace") { return ; } @@ -60,6 +85,7 @@ export function MessageBubble({ message }: MessageBubbleProps) { const empty = message.content.trim().length === 0; const media = message.media ?? []; + const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty; return (
{empty && message.isStreaming ? ( @@ -69,6 +95,27 @@ export function MessageBubble({ message }: MessageBubbleProps) { {message.content} {message.isStreaming && } {media.length > 0 ? : null} + {showAssistantActions ? ( +
+ +
+ ) : null} )}
diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx index b544fd0ba..52c8de47c 100644 --- a/webui/src/components/Sidebar.tsx +++ b/webui/src/components/Sidebar.tsx @@ -1,109 +1,121 @@ -import { Moon, PanelLeftClose, RefreshCcw, Settings, SquarePen, Sun } from "lucide-react"; +import { useMemo, useState } from "react"; +import { + PanelLeftClose, + Search, + SquarePen, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { ChatList } from "@/components/ChatList"; import { ConnectionBadge } from "@/components/ConnectionBadge"; import { Button } from "@/components/ui/button"; import { Separator } from "@/components/ui/separator"; +import { cn } from "@/lib/utils"; import type { ChatSummary } from "@/lib/types"; interface SidebarProps { sessions: ChatSummary[]; activeKey: string | null; loading: boolean; - theme: "light" | "dark"; - onToggleTheme: () => void; onNewChat: () => void; onSelect: (key: string) => void; - onRefresh: () => void; onRequestDelete: (key: string, label: string) => void; onCollapse: () => void; - activeView?: "chat" | "settings"; - onOpenSettings: () => void; } export function Sidebar(props: SidebarProps) { const { t } = useTranslation(); + const [query, setQuery] = useState(""); + const normalizedQuery = query.trim().toLowerCase(); + const filteredSessions = useMemo(() => { + if (!normalizedQuery) return props.sessions; + return props.sessions.filter((session) => { + const haystack = [ + session.preview, + session.chatId, + session.channel, + session.key, + ] + .filter(Boolean) + .join(" ") + .toLowerCase(); + return haystack.includes(normalizedQuery); + }); + }, [normalizedQuery, props.sessions]); + return ( - + ); } diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index d5e5dd65a..5f86190b1 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -10,7 +10,7 @@ import { ArrowUp, ImageIcon, Loader2, - Paperclip, + Plus, X, } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -219,8 +219,8 @@ export function ThreadComposer({ className={cn( "relative mx-auto flex w-full flex-col overflow-hidden transition-all duration-200", isHero - ? "max-w-[40rem] rounded-[24px] border border-border/75 bg-card shadow-[0_10px_30px_rgba(0,0,0,0.10)]" - : "max-w-[49.5rem] rounded-[16px] border border-border/70 bg-card", + ? "max-w-[58rem] rounded-[28px] border border-black/[0.035] bg-card shadow-[0_20px_55px_rgba(15,23,42,0.08)] dark:border-white/[0.06] dark:shadow-[0_24px_55px_rgba(0,0,0,0.34)]" + : "max-w-[49.5rem] rounded-[22px] border border-black/[0.035] bg-card shadow-[0_12px_30px_rgba(15,23,42,0.07)] dark:border-white/[0.06] dark:shadow-[0_16px_34px_rgba(0,0,0,0.28)]", "focus-within:ring-1 focus-within:ring-foreground/8", disabled && "opacity-60", isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary", @@ -268,9 +268,9 @@ export function ThreadComposer({ className={cn( "w-full resize-none bg-transparent", isHero - ? "min-h-[96px] px-4 pb-2 pt-4 text-[15px] leading-6" + ? "min-h-[78px] px-5 pb-2 pt-5 text-[16px] leading-6" : "min-h-[50px] px-4 pb-1.5 pt-3 text-sm", - "placeholder:text-muted-foreground", + "placeholder:text-muted-foreground/70", "focus:outline-none focus-visible:outline-none", "disabled:cursor-not-allowed", )} @@ -289,7 +289,7 @@ export function ThreadComposer({
@@ -310,10 +310,12 @@ export function ThreadComposer({ onClick={() => fileInputRef.current?.click()} className={cn( "rounded-full text-muted-foreground hover:text-foreground", - isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5", + isHero + ? "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card" + : "h-7.5 w-7.5 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card", )} > - + {modelLabel ? ( {modelLabel} ) : null} - - {t("thread.composer.sendHint")} - + {!isHero ? ( + + {t("thread.composer.sendHint")} + + ) : null}
- + +
+ + +
+
+ ); + } + return (
@@ -33,19 +82,34 @@ export function ThreadHeader({ > - +
+
+ +
+ +
diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 7dc2afaec..45b164b44 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -1,4 +1,13 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + BarChart3, + BookOpen, + ChevronRight, + Code2, + LayoutGrid, + Lightbulb, + MoreHorizontal, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { AskUserPrompt } from "@/components/thread/AskUserPrompt"; @@ -15,8 +24,13 @@ interface ThreadShellProps { session: ChatSummary | null; title: string; onToggleSidebar: () => void; - onGoHome: () => void; - onNewChat: () => Promise; + onGoHome?: () => void; + onNewChat?: () => void; + onCreateChat?: () => Promise; + onTurnEnd?: () => void; + theme?: "light" | "dark"; + onToggleTheme?: () => void; + onOpenSettings?: () => void; hideSidebarToggleOnDesktop?: boolean; } @@ -28,12 +42,24 @@ function toModelBadgeLabel(modelName: string | null): string | null { return leaf || trimmed; } +const QUICK_ACTION_KEYS = [ + { key: "plan", icon: LayoutGrid, tone: "text-[#f25b8f]" }, + { key: "analyze", icon: BarChart3, tone: "text-[#4f9de8]" }, + { key: "brainstorm", icon: Lightbulb, tone: "text-[#53c59d]" }, + { key: "code", icon: Code2, tone: "text-[#eba45d]" }, + { key: "summarize", icon: BookOpen, tone: "text-[#a877e7]" }, + { key: "more", icon: MoreHorizontal, tone: "text-muted-foreground/65" }, +] as const; + export function ThreadShell({ session, title, onToggleSidebar, - onGoHome, - onNewChat, + onCreateChat, + onTurnEnd, + theme = "light", + onToggleTheme = () => {}, + onOpenSettings = () => {}, hideSidebarToggleOnDesktop = false, }: ThreadShellProps) { const { t } = useTranslation(); @@ -57,7 +83,7 @@ export function ThreadShell({ setMessages, streamError, dismissStreamError, - } = useNanobotStream(chatId, initial, hasPendingToolCalls); + } = useNanobotStream(chatId, initial, hasPendingToolCalls, onTurnEnd); const showHeroComposer = messages.length === 0 && !loading; const pendingAsk = useMemo(() => { for (let index = messages.length - 1; index >= 0; index -= 1) { @@ -125,13 +151,94 @@ export function ThreadShell({ if (booting) return; setBooting(true); pendingFirstRef.current = content; - const newId = await onNewChat(); + const newId = await onCreateChat?.(); if (!newId) { pendingFirstRef.current = null; setBooting(false); } }, - [booting, onNewChat], + [booting, onCreateChat], + ); + + const handleQuickAction = useCallback( + (prompt: string) => { + if (session) { + send(prompt); + return; + } + void handleWelcomeSend(prompt); + }, + [handleWelcomeSend, send, session], + ); + + const quickActions = ( +
+ {QUICK_ACTION_KEYS.map(({ key, icon: Icon, tone }) => { + const title = t(`thread.empty.quickActions.${key}.title`); + const prompt = t(`thread.empty.quickActions.${key}.prompt`); + return ( + + ); + })} +
+ ); + + const composer = ( + <> + {streamError ? ( + + ) : null} + {pendingAsk ? ( + + ) : null} + {session ? ( + + ) : ( + + )} + {showHeroComposer ? quickActions : null} + ); const emptyState = loading ? ( @@ -139,20 +246,10 @@ export function ThreadShell({ {t("thread.loadingConversation")}
) : ( -
-
- - nanobot -
-

- {t("thread.empty.description")} -

+
+

+ {t("thread.empty.greeting")} +

); @@ -161,57 +258,17 @@ export function ThreadShell({ - {streamError ? ( - - ) : null} - {pendingAsk ? ( - - ) : null} - {session ? ( - - ) : ( - - )} - - } + composer={composer} />
); diff --git a/webui/src/components/thread/ThreadViewport.tsx b/webui/src/components/thread/ThreadViewport.tsx index 5f4b8d01a..7d4a80f06 100644 --- a/webui/src/components/thread/ThreadViewport.tsx +++ b/webui/src/components/thread/ThreadViewport.tsx @@ -82,9 +82,9 @@ export function ThreadViewport({
) : ( -
-
-
+
+
+
{emptyState}
{composer}
diff --git a/webui/src/globals.css b/webui/src/globals.css index 1c677432c..802009ee7 100644 --- a/webui/src/globals.css +++ b/webui/src/globals.css @@ -25,9 +25,9 @@ --input: 0 0% 89.8%; --ring: 0 0% 3.9%; --radius: 0.4375rem; - --sidebar: 0 0% 98%; + --sidebar: 0 0% 98.5%; --sidebar-foreground: 0 0% 3.9%; - --sidebar-accent: 0 0% 96.1%; + --sidebar-accent: 0 0% 95.8%; --sidebar-accent-foreground: 0 0% 9%; --sidebar-border: 0 0% 89.8%; } @@ -52,9 +52,9 @@ --border: 0 0% 18%; --input: 0 0% 18%; --ring: 0 0% 83.1%; - --sidebar: 0 0% 12%; + --sidebar: 0 0% 11.5%; --sidebar-foreground: 0 0% 98%; - --sidebar-accent: 0 0% 16%; + --sidebar-accent: 0 0% 15.5%; --sidebar-accent-foreground: 0 0% 98%; --sidebar-border: 0 0% 18%; } diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts index ec0312f7f..b25f5981a 100644 --- a/webui/src/hooks/useNanobotStream.ts +++ b/webui/src/hooks/useNanobotStream.ts @@ -38,6 +38,7 @@ export function useNanobotStream( chatId: string | null, initialMessages: UIMessage[] = [], hasPendingToolCalls = false, + onTurnEnd?: () => void, ): { messages: UIMessage[]; isStreaming: boolean; @@ -159,6 +160,12 @@ export function useNanobotStream( setMessages((prev) => prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)), ); + onTurnEnd?.(); + return; + } + + if (ev.event === "session_updated") { + onTurnEnd?.(); return; } @@ -233,7 +240,7 @@ export function useNanobotStream( streamEndTimerRef.current = null; } }; - }, [chatId, client]); + }, [chatId, client, onTurnEnd]); const send = useCallback( (content: string, images?: SendImage[]) => { diff --git a/webui/src/hooks/useSessions.ts b/webui/src/hooks/useSessions.ts index d16c2a118..e05e16a20 100644 --- a/webui/src/hooks/useSessions.ts +++ b/webui/src/hooks/useSessions.ts @@ -61,6 +61,7 @@ export function useSessions(): { chatId, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), + title: "", preview: "", }, ...prev.filter((s) => s.key !== key), @@ -221,7 +222,7 @@ export function sessionTitle( firstUserMessage?: string, ): string { return deriveTitle( - firstUserMessage || session.preview, + session.title || firstUserMessage || session.preview, i18n.t("chat.newChat"), ); } diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index 4ae832827..90e2532c3 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -18,11 +18,19 @@ } }, "sidebar": { + "navigation": "Sidebar navigation", + "globalActions": "Global actions", "collapse": "Collapse sidebar", "toggleTheme": "Toggle theme", + "home": "Home", "newChat": "New chat", + "searchAria": "Search chats", + "searchPlaceholder": "Search chats", + "searchResults": "Results", + "noSearchResults": "No matching chats.", "recent": "Recent", "refreshSessions": "Refresh sessions", + "settings": "Settings", "language": { "label": "Language", "ariaLabel": "Change language" @@ -34,7 +42,12 @@ "noSessions": "No sessions yet.", "actions": "Chat actions for {{title}}", "delete": "Delete", - "newChat": "New chat" + "newChat": "New chat", + "groups": { + "today": "Today", + "yesterday": "Yesterday", + "earlier": "Earlier" + } }, "deleteConfirm": { "title": "Delete “{{title}}”?", @@ -53,20 +66,55 @@ "thread": { "loadingConversation": "Loading conversation…", "empty": { - "description": "Ask questions, continue local work, or start a new thread." + "greeting": "What can I do for you?", + "quickActions": { + "plan": { + "title": "Create a project plan", + "prompt": "Create a concise project plan for what I should build next." + }, + "analyze": { + "title": "Analyze this data", + "prompt": "Help me analyze this data and call out the most important patterns." + }, + "brainstorm": { + "title": "Brainstorm ideas", + "prompt": "Brainstorm a few practical ideas and tradeoffs for this problem." + }, + "code": { + "title": "Write code", + "prompt": "Help me write the code for this task, starting with the smallest useful change." + }, + "summarize": { + "title": "Summarize this document", + "prompt": "Summarize this document and list the key takeaways." + }, + "more": { + "title": "More", + "prompt": "Show me a few useful ways you can help in this workspace." + } + } }, "header": { - "toggleSidebar": "Toggle sidebar" + "toggleSidebar": "Toggle sidebar", + "newChat": "Start a new chat", + "toggleTheme": "Toggle theme from header", + "settings": "Open settings" }, "composer": { "placeholderThread": "Type your message…", - "placeholderHero": "What's on your mind?", + "placeholderHero": "Ask anything...", "placeholderOpening": "Opening a new chat…", "placeholderStreaming": "Model is responding…", "inputAria": "Message input", "sendHint": "Enter to send · Shift+Enter for newline", "send": "Send message", "attachImage": "Attach image", + "tools": { + "search": "Search", + "reason": "Reason", + "deepResearch": "Deep research", + "voice": "Voice input" + }, "encoding": "Encoding…", "remove": "Remove attachment", "normalizedSizeHint": "{{orig}} → {{current}} (auto)", @@ -86,7 +134,9 @@ "assistantTyping": "Assistant is typing", "toolSingle": "Using a tool", "toolMany": "Used {{count}} tools", - "imageAttachment": "Image attachment" + "imageAttachment": "Image attachment", + "copyReply": "Copy reply", + "copiedReply": "Copied reply" }, "lightbox": { "title": "Image preview", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 347fec179..57c822317 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -18,11 +18,19 @@ } }, "sidebar": { + "navigation": "侧边栏导航", + "globalActions": "全局操作", "collapse": "收起侧边栏", "toggleTheme": "切换主题", + "home": "首页", "newChat": "新建对话", + "searchAria": "搜索会话", + "searchPlaceholder": "搜索会话", + "searchResults": "搜索结果", + "noSearchResults": "没有匹配的会话。", "recent": "最近对话", "refreshSessions": "刷新会话", + "settings": "设置", "language": { "label": "语言", "ariaLabel": "切换语言" @@ -34,7 +42,12 @@ "noSessions": "还没有会话。", "actions": "“{{title}}” 的会话操作", "delete": "删除", - "newChat": "新建对话" + "newChat": "新建对话", + "groups": { + "today": "今天", + "yesterday": "昨天", + "earlier": "更早" + } }, "deleteConfirm": { "title": "删除“{{title}}”?", @@ -53,20 +66,55 @@ "thread": { "loadingConversation": "正在加载对话…", "empty": { - "description": "可以提问、继续本地工作,或者开启一个新线程。" + "greeting": "我可以帮你做什么?", + "quickActions": { + "plan": { + "title": "创建项目计划", + "prompt": "帮我为接下来要做的事情写一份简洁的项目计划。" + }, + "analyze": { + "title": "分析这些数据", + "prompt": "帮我分析这些数据,并指出最重要的模式。" + }, + "brainstorm": { + "title": "头脑风暴想法", + "prompt": "围绕这个问题头脑风暴几个实用方案,并说明取舍。" + }, + "code": { + "title": "编写代码", + "prompt": "帮我为这个任务写代码,先从最小可用改动开始。" + }, + "summarize": { + "title": "总结这份文档", + "prompt": "帮我总结这份文档,并列出关键要点。" + }, + "more": { + "title": "更多", + "prompt": "展示几个你在这个工作区里可以帮我的实用方式。" + } + } }, "header": { - "toggleSidebar": "切换侧边栏" + "toggleSidebar": "切换侧边栏", + "newChat": "从顶部新建对话", + "toggleTheme": "从顶部切换主题", + "settings": "打开设置" }, "composer": { "placeholderThread": "输入消息…", - "placeholderHero": "你在想什么?", + "placeholderHero": "问任何问题...", "placeholderOpening": "正在打开新对话…", "placeholderStreaming": "模型正在回复…", "inputAria": "消息输入框", "sendHint": "Enter 发送 · Shift+Enter 换行", "send": "发送消息", "attachImage": "添加图片", + "tools": { + "search": "搜索", + "reason": "推理", + "deepResearch": "深度研究", + "voice": "语音输入" + }, "encoding": "处理中…", "remove": "移除附件", "normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)", @@ -86,7 +134,9 @@ "assistantTyping": "助手正在输入", "toolSingle": "正在使用工具", "toolMany": "已使用 {{count}} 个工具", - "imageAttachment": "图片附件" + "imageAttachment": "图片附件", + "copyReply": "复制回复", + "copiedReply": "已复制回复" }, "lightbox": { "title": "图片预览", diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 56fed32c7..95deb9b06 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -42,6 +42,7 @@ export async function listSessions( key: string; created_at: string | null; updated_at: string | null; + title?: string; preview?: string; }; const body = await request<{ sessions: Row[] }>( @@ -53,6 +54,7 @@ export async function listSessions( ...splitKey(s.key), createdAt: s.created_at, updatedAt: s.updated_at, + title: s.title ?? "", preview: s.preview ?? "", })); } diff --git a/webui/src/lib/nanobot-client.ts b/webui/src/lib/nanobot-client.ts index f5039f93f..2162cf439 100644 --- a/webui/src/lib/nanobot-client.ts +++ b/webui/src/lib/nanobot-client.ts @@ -185,8 +185,8 @@ export class NanobotClient { this.knownChats.add(chatId); const frame: Outbound = media && media.length > 0 - ? { type: "message", chat_id: chatId, content, media } - : { type: "message", chat_id: chatId, content }; + ? { type: "message", chat_id: chatId, content, media, webui: true } + : { type: "message", chat_id: chatId, content, webui: true }; this.queueSend(frame); } diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index e4c09ba16..c2428115d 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -56,6 +56,7 @@ export interface ChatSummary { chatId: string; createdAt: string | null; updatedAt: string | null; + title?: string; preview: string; } @@ -125,6 +126,7 @@ export type InboundEvent = stream_id?: string; } | { event: "turn_end"; chat_id: string } + | { event: "session_updated"; chat_id: string } | { event: "error"; chat_id?: string; detail?: string }; /** Base64-encoded image attached to an outbound ``message`` envelope. @@ -148,4 +150,7 @@ export type Outbound = chat_id: string; content: string; media?: OutboundMedia[]; + /** Marks messages sent by the embedded WebUI, without changing the + * generic websocket protocol for other clients. */ + webui?: true; }; diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index aab940d5c..dc387d241 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { deleteSession, fetchSessionMessages, updateSettings } from "@/lib/api"; +import { deleteSession, fetchSessionMessages, listSessions, updateSettings } from "@/lib/api"; describe("webui API helpers", () => { beforeEach(() => { @@ -48,4 +48,28 @@ describe("webui API helpers", () => { }), ); }); + + it("maps generated session titles from the sessions list", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + sessions: [ + { + key: "websocket:chat-1", + created_at: "2026-05-01T10:00:00", + updated_at: "2026-05-01T10:01:00", + title: "优化 WebUI 标题", + }, + ], + }), + } as Response); + + await expect(listSessions("tok")).resolves.toMatchObject([ + { + key: "websocket:chat-1", + title: "优化 WebUI 标题", + preview: "", + }, + ]); + }); }); diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 77b9420dd..800fb82aa 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { ChatSummary } from "@/lib/types"; @@ -7,6 +7,7 @@ const connectSpy = vi.fn(); const refreshSpy = vi.fn(); const createChatSpy = vi.fn().mockResolvedValue("chat-1"); const deleteChatSpy = vi.fn(); +const toggleThemeSpy = vi.fn(); let mockSessions: ChatSummary[] = []; vi.mock("@/hooks/useSessions", async (importOriginal) => { @@ -34,7 +35,7 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => { vi.mock("@/hooks/useTheme", () => ({ useTheme: () => ({ theme: "light" as const, - toggle: vi.fn(), + toggle: toggleThemeSpy, }), })); @@ -74,6 +75,7 @@ describe("App layout", () => { refreshSpy.mockReset(); createChatSpy.mockClear(); deleteChatSpy.mockReset(); + toggleThemeSpy.mockReset(); vi.stubGlobal( "fetch", vi.fn().mockResolvedValue({ @@ -121,8 +123,11 @@ describe("App layout", () => { render(); await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); await waitFor(() => - expect(screen.getByRole("button", { name: /^First chat$/ })).toBeInTheDocument(), + expect( + within(sidebar).getByRole("button", { name: /^First chat$/ }), + ).toBeInTheDocument(), ); fireEvent.pointerDown(screen.getByLabelText("Chat actions for First chat"), { @@ -140,14 +145,24 @@ describe("App layout", () => { ); await waitFor(() => expect( - screen.getByRole("button", { name: /^Second chat$/ }), + within(sidebar).getByRole("button", { name: /^Second chat$/ }), ).toBeInTheDocument(), ); expect(screen.queryByText('Delete “First chat”?')).not.toBeInTheDocument(); expect(document.body.style.pointerEvents).not.toBe("none"); }, 15_000); - it("opens the Cursor-style settings view from the sidebar", async () => { + it("opens the Cursor-style settings view from the header", async () => { + mockSessions = [ + { + key: "websocket:chat-a", + channel: "websocket", + chatId: "chat-a", + createdAt: "2026-04-16T10:00:00Z", + updatedAt: "2026-04-16T10:00:00Z", + preview: "Existing chat", + }, + ]; vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL) => { @@ -180,10 +195,95 @@ describe("App layout", () => { render(); await waitFor(() => expect(connectSpy).toHaveBeenCalled()); - fireEvent.click(screen.getByRole("button", { name: "Settings" })); + fireEvent.click(screen.getByRole("button", { name: "Open settings" })); expect(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument(); expect(screen.getByText("AI")).toBeInTheDocument(); expect(screen.getByDisplayValue("openai/gpt-4o")).toBeInTheDocument(); }); + + it("filters sidebar sessions through the lightweight search row", async () => { + mockSessions = [ + { + key: "websocket:chat-alpha", + channel: "websocket", + chatId: "chat-alpha", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + preview: "Project planning notes", + }, + { + key: "websocket:chat-beta", + channel: "websocket", + chatId: "chat-beta", + createdAt: "2026-04-15T10:00:00Z", + updatedAt: "2026-04-15T10:00:00Z", + preview: "Travel ideas", + }, + ]; + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + expect(within(sidebar).getByText("Project planning notes")).toBeInTheDocument(); + expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument(); + + fireEvent.change(screen.getByRole("textbox", { name: "Search chats" }), { + target: { value: "travel" }, + }); + + expect(within(sidebar).queryByText("Project planning notes")).not.toBeInTheDocument(); + expect(within(sidebar).getByText("Travel ideas")).toBeInTheDocument(); + }); + + it("opens a blank start page without creating an empty chat", async () => { + mockSessions = [ + { + key: "websocket:chat-a", + channel: "websocket", + chatId: "chat-a", + createdAt: "2026-04-16T10:00:00Z", + updatedAt: "2026-04-16T10:00:00Z", + preview: "Existing chat", + }, + ]; + + const matchMedia = vi.fn().mockImplementation((query: string) => ({ + matches: query.includes("1024px"), + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })); + vi.stubGlobal("matchMedia", matchMedia); + + const { container } = render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + + fireEvent.click(screen.getByRole("button", { name: "Toggle theme from header" })); + expect(toggleThemeSpy).toHaveBeenCalledTimes(1); + + fireEvent.click(screen.getByRole("button", { name: "Collapse sidebar" })); + const desktopAside = container.querySelector("aside.lg\\:block") as HTMLElement; + await waitFor(() => expect(desktopAside.style.width).toBe("0px")); + + expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Toggle sidebar" })); + await waitFor(() => expect(desktopAside.style.width).toBe("272px")); + + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + fireEvent.click(within(sidebar).getByRole("button", { name: "New chat" })); + expect(createChatSpy).not.toHaveBeenCalled(); + expect(screen.getByText("What can I do for you?")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Start a new chat" })).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Toggle theme from header" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Open settings" })).toBeInTheDocument(); + + expect(within(sidebar).getByText("Existing chat")).toBeInTheDocument(); + }); }); diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx index e8dec29ab..773c143c7 100644 --- a/webui/src/tests/message-bubble.test.tsx +++ b/webui/src/tests/message-bubble.test.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; import { MessageBubble } from "@/components/MessageBubble"; import type { UIMessage } from "@/lib/types"; @@ -19,6 +19,44 @@ describe("MessageBubble", () => { expect(row).toHaveClass("ml-auto", "flex"); expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]"); + expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument(); + }); + + it("copies completed assistant replies from the action row", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + const message: UIMessage = { + id: "a-copy", + role: "assistant", + content: "I can help with the next step.", + createdAt: Date.now(), + }; + + render(); + + fireEvent.click(screen.getByRole("button", { name: "Copy reply" })); + + expect(writeText).toHaveBeenCalledWith("I can help with the next step."); + await waitFor(() => + expect(screen.getByRole("button", { name: "Copied reply" })).toBeInTheDocument(), + ); + }); + + it("does not show copy actions for streaming placeholders", () => { + const message: UIMessage = { + id: "a-streaming", + role: "assistant", + content: "", + isStreaming: true, + createdAt: Date.now(), + }; + + render(); + + expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument(); }); it("renders trace messages as collapsible tool groups", () => { diff --git a/webui/src/tests/nanobot-client.test.ts b/webui/src/tests/nanobot-client.test.ts index b95ef6804..4c7923999 100644 --- a/webui/src/tests/nanobot-client.test.ts +++ b/webui/src/tests/nanobot-client.test.ts @@ -116,7 +116,7 @@ describe("NanobotClient", () => { // Attach is sent first because sendMessage adds to knownChats, which // handleOpen re-attaches; then the queued message follows. expect(lastSocket().sent).toContain( - JSON.stringify({ type: "message", chat_id: "chat-x", content: "hello" }), + JSON.stringify({ type: "message", chat_id: "chat-x", content: "hello", webui: true }), ); }); @@ -196,6 +196,7 @@ describe("NanobotClient", () => { chat_id: "chat-x", content: "look", media: [{ data_url: "data:image/png;base64,AAAA", name: "shot.png" }], + webui: true, }); }); @@ -214,6 +215,7 @@ describe("NanobotClient", () => { type: "message", chat_id: "chat-x", content: "hello", + webui: true, }); }); diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index 17205fb67..3d5c14e75 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -9,15 +9,38 @@ describe("ThreadComposer", () => { , ); expect(screen.getByText("claude-opus-4-5")).toBeInTheDocument(); - const input = screen.getByPlaceholderText("What's on your mind?"); + expect(screen.queryByRole("button", { name: "Search" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Reason" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Deep research" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Voice input" })).not.toBeInTheDocument(); + const input = screen.getByPlaceholderText("Ask anything..."); expect(input).toBeInTheDocument(); - expect(input.className).toContain("min-h-[96px]"); - expect(input.parentElement?.className).toContain("max-w-[40rem]"); + expect(input.className).toContain("min-h-[78px]"); + expect(input.parentElement?.className).toContain("max-w-[58rem]"); + }); + + it("keeps the thread composer compact while matching the hero style", () => { + render( + , + ); + + expect(screen.getByText("gpt-4o")).toBeInTheDocument(); + const input = screen.getByPlaceholderText("Type your message..."); + expect(input.className).toContain("min-h-[50px]"); + expect(input.parentElement?.className).toContain("max-w-[49.5rem]"); + expect(input.parentElement?.className).toContain("rounded-[22px]"); + expect(input.parentElement?.className).toContain("shadow-[0_12px_30px_rgba(15,23,42,0.07)]"); + expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card"); + expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground"); }); }); diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index f5dea5960..68a81d1e1 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -86,6 +86,26 @@ describe("ThreadShell", () => { ); }); + it("does not navigate away when clicking the chat title", async () => { + const client = makeClient(); + const onGoHome = vi.fn(); + render(wrap( + client, + {}} + onGoHome={onGoHome} + onNewChat={() => {}} + />, + )); + + await waitFor(() => expect(screen.getByText("Important conversation")).toBeInTheDocument()); + fireEvent.click(screen.getByText("Important conversation")); + + expect(onGoHome).not.toHaveBeenCalled(); + }); + it("restores in-memory messages when switching away and back to a session", async () => { const client = makeClient(); const onNewChat = vi.fn().mockResolvedValue("chat-a"); @@ -199,7 +219,67 @@ describe("ThreadShell", () => { await waitFor(() => { expect(screen.queryByText("delete me cleanly")).not.toBeInTheDocument(); }); - expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument(); + expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument(); + }); + + it("creates a chat only when the blank landing sends a first message", async () => { + const client = makeClient(); + const onNewChat = vi.fn(); + const onCreateChat = vi.fn().mockResolvedValue("chat-new"); + + render( + wrap( + client, + {}} + onGoHome={() => {}} + onNewChat={onNewChat} + onCreateChat={onCreateChat} + />, + ), + ); + + fireEvent.change(screen.getByLabelText("Message input"), { + target: { value: "start for real" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Send message" })); + + await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1)); + expect(onNewChat).not.toHaveBeenCalled(); + }); + + it("sends quick action prompts from the empty thread landing", async () => { + const client = makeClient(); + const onNewChat = vi.fn().mockResolvedValue("chat-a"); + + render( + wrap( + client, + {}} + onGoHome={() => {}} + onNewChat={onNewChat} + />, + ), + ); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Write code" })).toBeInTheDocument(); + }); + + fireEvent.click(screen.getByRole("button", { name: "Write code" })); + + await waitFor(() => + expect(client.sendMessage).toHaveBeenCalledWith( + "chat-a", + "Help me write the code for this task, starting with the smallest useful change.", + undefined, + ), + ); }); it("does not leak the previous thread when opening a brand-new chat", async () => { @@ -260,10 +340,10 @@ describe("ThreadShell", () => { expect(screen.queryByText("old answer")).not.toBeInTheDocument(); await waitFor(() => - expect(screen.getByPlaceholderText("What's on your mind?")).toBeInTheDocument(), + expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument(), ); - const input = screen.getByPlaceholderText("What's on your mind?"); - expect(input.className).toContain("min-h-[96px]"); + const input = screen.getByPlaceholderText("Ask anything..."); + expect(input.className).toContain("min-h-[78px]"); expect(screen.queryByText("old answer")).not.toBeInTheDocument(); }); diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx index 2c7173174..155ec118e 100644 --- a/webui/src/tests/useNanobotStream.test.tsx +++ b/webui/src/tests/useNanobotStream.test.tsx @@ -159,7 +159,8 @@ describe("useNanobotStream", () => { it("keeps streaming alive across stream_end and completes on turn_end", () => { const fake = fakeClient(); - const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES), { + const onTurnEnd = vi.fn(); + const { result } = renderHook(() => useNanobotStream("chat-s", EMPTY_MESSAGES, false, onTurnEnd), { wrapper: wrap(fake.client), }); @@ -211,5 +212,23 @@ describe("useNanobotStream", () => { expect(result.current.isStreaming).toBe(false); expect(result.current.messages.every((message) => !message.isStreaming)).toBe(true); + expect(onTurnEnd).toHaveBeenCalledTimes(1); + }); + + it("refreshes session metadata when the server reports a session update", () => { + const fake = fakeClient(); + const onTurnEnd = vi.fn(); + renderHook(() => useNanobotStream("chat-title", EMPTY_MESSAGES, false, onTurnEnd), { + wrapper: wrap(fake.client), + }); + + act(() => { + fake.emit("chat-title", { + event: "session_updated", + chat_id: "chat-title", + }); + }); + + expect(onTurnEnd).toHaveBeenCalledTimes(1); }); });