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 14:15:36 +00:00 committed by Xubin Ren
parent d8fd4c80bf
commit 790a03ec28
33 changed files with 1270 additions and 312 deletions

View File

@ -55,6 +55,7 @@ from nanobot.utils.progress_events import (
on_progress_accepts_tool_events, on_progress_accepts_tool_events,
) )
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE 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: if TYPE_CHECKING:
from nanobot.config.schema import ChannelsConfig, ExecToolConfig, ToolsConfig, WebToolsConfig from nanobot.config.schema import ChannelsConfig, ExecToolConfig, ToolsConfig, WebToolsConfig
@ -814,6 +815,25 @@ class AgentLoop:
channel=msg.channel, chat_id=msg.chat_id, channel=msg.channel, chat_id=msg.chat_id,
content="", metadata={**msg.metadata, "_turn_end": True}, 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: except asyncio.CancelledError:
logger.info("Task cancelled for session {}", session_key) logger.info("Task cancelled for session {}", session_key)
# Preserve partial context from the interrupted turn so # Preserve partial context from the interrupted turn so
@ -1003,6 +1023,7 @@ class AgentLoop:
key = session_key or msg.session_key key = session_key or msg.session_key
session = self.sessions.get_or_create(key) session = self.sessions.get_or_create(key)
mark_webui_session(session, msg.metadata)
if self._restore_runtime_checkpoint(session): if self._restore_runtime_checkpoint(session):
self.sessions.save(session) self.sessions.save(session)
if self._restore_pending_user_turn(session): if self._restore_pending_user_turn(session):

View File

@ -1184,12 +1184,15 @@ class WebSocketChannel(BaseChannel):
# Auto-attach on first use so clients can one-shot without a separate attach. # Auto-attach on first use so clients can one-shot without a separate attach.
self._attach(connection, cid) 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( await self._handle_message(
sender_id=client_id, sender_id=client_id,
chat_id=cid, chat_id=cid,
content=content, content=content,
media=media_paths or None, media=media_paths or None,
metadata={"remote": getattr(connection, "remote_address", None)}, metadata=metadata,
) )
return return
await self._send_event(connection, "error", detail=f"unknown type: {t!r}") 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"): if msg.metadata.get("_turn_end"):
await self.send_turn_end(msg.chat_id) await self.send_turn_end(msg.chat_id)
return return
if msg.metadata.get("_session_updated"):
await self.send_session_updated(msg.chat_id)
return
text = msg.content text = msg.content
if msg.buttons: if msg.buttons:
text = _append_buttons_as_text(text, msg.buttons) text = _append_buttons_as_text(text, msg.buttons)
@ -1299,3 +1305,13 @@ class WebSocketChannel(BaseChannel):
raw = json.dumps(body, ensure_ascii=False) raw = json.dumps(body, ensure_ascii=False)
for connection in conns: for connection in conns:
await self._safe_send_to(connection, raw, label=" turn_end ") 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 ")

View File

@ -547,10 +547,13 @@ class SessionManager:
data = json.loads(first_line) data = json.loads(first_line)
if data.get("_type") == "metadata": if data.get("_type") == "metadata":
key = data.get("key") or path.stem.replace("_", ":", 1) 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({ sessions.append({
"key": key, "key": key,
"created_at": data.get("created_at"), "created_at": data.get("created_at"),
"updated_at": data.get("updated_at"), "updated_at": data.get("updated_at"),
"title": title if isinstance(title, str) else "",
"path": str(path) "path": str(path)
}) })
except Exception: except Exception:
@ -560,6 +563,11 @@ class SessionManager:
"key": repaired.key, "key": repaired.key,
"created_at": repaired.created_at.isoformat(), "created_at": repaired.created_at.isoformat(),
"updated_at": repaired.updated_at.isoformat(), "updated_at": repaired.updated_at.isoformat(),
"title": (
repaired.metadata.get("title")
if isinstance(repaired.metadata.get("title"), str)
else ""
),
"path": str(path) "path": str(path)
}) })
continue continue

View File

@ -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,
)

View File

@ -1,5 +1,6 @@
"""Tests for structured tool-event progress metadata emitted by AgentLoop.""" """Tests for structured tool-event progress metadata emitted by AgentLoop."""
import asyncio
from pathlib import Path from pathlib import Path
from unittest.mock import AsyncMock, MagicMock 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].metadata or {}).get("_turn_end") is True
assert outbound[-1].chat_id == "chat1" 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 @pytest.mark.asyncio
async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None: async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None:
bus = MessageBus() bus = MessageBus()

View File

@ -8,7 +8,13 @@ from nanobot.agent.context import ContextBuilder
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
from nanobot.session.manager import Session 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: def _mk_loop() -> AgentLoop:
@ -22,9 +28,56 @@ def _mk_loop() -> AgentLoop:
def _make_full_loop(tmp_path: Path) -> AgentLoop: def _make_full_loop(tmp_path: Path) -> AgentLoop:
provider = MagicMock() provider = MagicMock()
provider.get_default_model.return_value = "test-model" 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") 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: def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None:
loop = _mk_loop() loop = _mk_loop()
session = Session(key="test:runtime-only") session = Session(key="test:runtime-only")

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: 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) --- # --- Original regression test (from PR 2075) ---
def test_get_history_drops_orphan_tool_results_when_window_cuts_tool_calls(): def test_get_history_drops_orphan_tool_results_when_window_cuts_tool_calls():

View File

@ -167,6 +167,40 @@ def test_issue_route_secret_matches_empty_secret() -> None:
assert _issue_route_secret_matches(Headers([("Authorization", "Bearer anything")]), "") is True 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 @pytest.mark.asyncio
async def test_send_delivers_json_message_with_media_and_reply() -> None: async def test_send_delivers_json_message_with_media_and_reply() -> None:
bus = MagicMock() 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"} 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 @pytest.mark.asyncio
async def test_send_non_connection_closed_exception_is_raised() -> None: async def test_send_non_connection_closed_exception_is_raised() -> None:
bus = MagicMock() bus = MagicMock()

View File

@ -25,7 +25,7 @@ type BootState =
}; };
const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar"; const SIDEBAR_STORAGE_KEY = "nanobot-webui.sidebar";
const SIDEBAR_WIDTH = 279; const SIDEBAR_WIDTH = 272;
type ShellView = "chat" | "settings"; type ShellView = "chat" | "settings";
function readSidebarOpen(): boolean { function readSidebarOpen(): boolean {
@ -99,13 +99,6 @@ export default function App() {
return ( return (
<div className="flex h-full w-full items-center justify-center"> <div className="flex h-full w-full items-center justify-center">
<div className="flex flex-col items-center gap-3 animate-in fade-in-0 duration-300"> <div className="flex flex-col items-center gap-3 animate-in fade-in-0 duration-300">
<img
src="/brand/nanobot_icon.png"
alt=""
className="h-10 w-10 animate-pulse select-none"
aria-hidden
draggable={false}
/>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<span className="relative flex h-2 w-2"> <span className="relative flex h-2 w-2">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-foreground/40" /> <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-foreground/40" />
@ -121,13 +114,6 @@ export default function App() {
return ( return (
<div className="flex h-full w-full items-center justify-center px-4 text-center"> <div className="flex h-full w-full items-center justify-center px-4 text-center">
<div className="flex max-w-md flex-col items-center gap-3"> <div className="flex max-w-md flex-col items-center gap-3">
<img
src="/brand/nanobot_icon.png"
alt=""
className="h-10 w-10 opacity-60 grayscale select-none"
aria-hidden
draggable={false}
/>
<p className="text-lg font-semibold">{t("app.error.title")}</p> <p className="text-lg font-semibold">{t("app.error.title")}</p>
<p className="text-sm text-muted-foreground">{state.message}</p> <p className="text-sm text-muted-foreground">{state.message}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
@ -213,7 +199,7 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
} }
}, []); }, []);
const onNewChat = useCallback(async () => { const onCreateChat = useCallback(async () => {
try { try {
const chatId = await createChat(); const chatId = await createChat();
setActiveKey(`websocket:${chatId}`); setActiveKey(`websocket:${chatId}`);
@ -226,6 +212,12 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
} }
}, [createChat]); }, [createChat]);
const onNewChat = useCallback(() => {
setActiveKey(null);
setView("chat");
setMobileSidebarOpen(false);
}, []);
const onSelectChat = useCallback( const onSelectChat = useCallback(
(key: string) => { (key: string) => {
setActiveKey(key); 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 () => { const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return; if (!pendingDelete) return;
const key = pendingDelete.key; const key = pendingDelete.key;
@ -254,7 +255,8 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
}, [pendingDelete, deleteChat, activeKey, sessions]); }, [pendingDelete, deleteChat, activeKey, sessions]);
const headerTitle = activeSession const headerTitle = activeSession
? activeSession.preview || ? activeSession.title ||
activeSession.preview ||
t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) }) t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) })
: t("app.brand"); : t("app.brand");
@ -268,20 +270,10 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
sessions, sessions,
activeKey, activeKey,
loading, loading,
theme, onNewChat,
onToggleTheme: toggle,
onNewChat: () => {
void onNewChat();
},
onSelect: onSelectChat, onSelect: onSelectChat,
onRefresh: () => void refresh(),
onRequestDelete: (key: string, label: string) => onRequestDelete: (key: string, label: string) =>
setPendingDelete({ key, label }), setPendingDelete({ key, label }),
activeView: view,
onOpenSettings: () => {
setView("settings" as const);
setMobileSidebarOpen(false);
},
}; };
return ( return (
@ -296,10 +288,11 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
> >
<div <div
className={cn( className={cn(
"absolute inset-y-0 left-0 h-full w-[279px] overflow-hidden bg-sidebar shadow-inner-right", "absolute inset-y-0 left-0 h-full overflow-hidden bg-sidebar shadow-inner-right",
"transition-transform duration-300 ease-out", "transition-transform duration-300 ease-out",
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full", desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
)} )}
style={{ width: SIDEBAR_WIDTH }}
> >
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} /> <Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
</div> </div>
@ -312,7 +305,8 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
<SheetContent <SheetContent
side="left" side="left"
showCloseButton={false} showCloseButton={false}
className="w-[279px] p-0 sm:max-w-[279px] lg:hidden" className="p-0 lg:hidden"
style={{ width: SIDEBAR_WIDTH, maxWidth: SIDEBAR_WIDTH }}
> >
<Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} /> <Sidebar {...sidebarProps} onCollapse={closeMobileSidebar} />
</SheetContent> </SheetContent>
@ -331,8 +325,12 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
session={activeSession} session={activeSession}
title={headerTitle} title={headerTitle}
onToggleSidebar={toggleSidebar} onToggleSidebar={toggleSidebar}
onGoHome={() => setActiveKey(null)}
onNewChat={onNewChat} onNewChat={onNewChat}
onCreateChat={onCreateChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
onOpenSettings={onOpenSettings}
hideSidebarToggleOnDesktop={desktopSidebarOpen} hideSidebarToggleOnDesktop={desktopSidebarOpen}
/> />
)} )}

View File

@ -8,7 +8,6 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { ScrollArea } from "@/components/ui/scroll-area"; import { ScrollArea } from "@/components/ui/scroll-area";
import { relativeTime } from "@/lib/format";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ChatSummary } from "@/lib/types"; import type { ChatSummary } from "@/lib/types";
@ -18,10 +17,11 @@ interface ChatListProps {
onSelect: (key: string) => void; onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void; onRequestDelete: (key: string, label: string) => void;
loading?: boolean; loading?: boolean;
emptyLabel?: string;
} }
function titleFor(s: ChatSummary, fallbackTitle: string): 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; if (p) return p.length > 48 ? `${p.slice(0, 45)}` : p;
return fallbackTitle; return fallbackTitle;
} }
@ -32,6 +32,7 @@ export function ChatList({
onSelect, onSelect,
onRequestDelete, onRequestDelete,
loading, loading,
emptyLabel,
}: ChatListProps) { }: ChatListProps) {
const { t } = useTranslation(); const { t } = useTranslation();
if (loading && sessions.length === 0) { if (loading && sessions.length === 0) {
@ -44,73 +45,111 @@ export function ChatList({
if (sessions.length === 0) { if (sessions.length === 0) {
return ( return (
<div className="px-3 py-6 text-xs text-muted-foreground"> <div className="px-3 py-6 text-[12px] leading-5 text-muted-foreground/80">
{t("chat.noSessions")} {emptyLabel ?? t("chat.noSessions")}
</div> </div>
); );
} }
const groups = groupSessions(sessions, {
today: t("chat.groups.today"),
yesterday: t("chat.groups.yesterday"),
earlier: t("chat.groups.earlier"),
});
return ( return (
<ScrollArea className="h-full"> <ScrollArea className="h-full">
<ul className="space-y-0.5 px-2 py-1"> <div className="space-y-3 px-2 py-1.5">
{sessions.map((s) => { {groups.map((group) => (
const active = s.key === activeKey; <section key={group.label} aria-label={group.label}>
const title = titleFor( <div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
s, {group.label}
t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }), </div>
); <ul className="space-y-0.5">
return ( {group.sessions.map((s) => {
<li key={s.key}> const active = s.key === activeKey;
<div const title = titleFor(
className={cn( s,
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-[12.5px] transition-colors", t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }),
active );
? "bg-sidebar-accent/80 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--border)/0.4)]" return (
: "text-sidebar-foreground/88 hover:bg-sidebar-accent/45", <li key={s.key}>
)} <div
> className={cn(
<button "group flex min-h-8 items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
type="button" active
onClick={() => onSelect(s.key)} ? "bg-sidebar-accent/70 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.28)]"
className="flex min-w-0 flex-1 flex-col items-start text-left" : "text-sidebar-foreground/82 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
> )}
<span className="w-full truncate font-medium leading-5">{title}</span>
<span className="text-[10.5px] text-muted-foreground/80">
{relativeTime(s.updatedAt ?? s.createdAt) || "—"}
</span>
</button>
<DropdownMenu modal={false}>
<DropdownMenuTrigger
className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
"focus-visible:opacity-100",
active && "opacity-100",
)}
aria-label={t("chat.actions", { title })}
>
<MoreHorizontal className="h-4 w-4" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
onSelect={() => {
window.setTimeout(() => onRequestDelete(s.key, title), 0);
}}
className="text-destructive focus:text-destructive"
> >
<Trash2 className="mr-2 h-4 w-4" /> <button
{t("chat.delete")} type="button"
</DropdownMenuItem> onClick={() => onSelect(s.key)}
</DropdownMenuContent> className="min-w-0 flex-1 py-1.5 text-left"
</DropdownMenu> >
</div> <span className="block w-full truncate font-medium leading-5">{title}</span>
</li> </button>
); <DropdownMenu modal={false}>
})} <DropdownMenuTrigger
</ul> className={cn(
"inline-flex h-6 w-6 items-center justify-center rounded-md text-muted-foreground/75 opacity-0 transition-opacity",
"hover:bg-sidebar-accent hover:text-sidebar-foreground group-hover:opacity-100",
"focus-visible:opacity-100",
active && "opacity-100",
)}
aria-label={t("chat.actions", { title })}
>
<MoreHorizontal className="h-3.5 w-3.5" />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
onCloseAutoFocus={(event) => event.preventDefault()}
>
<DropdownMenuItem
onSelect={() => {
window.setTimeout(() => onRequestDelete(s.key, title), 0);
}}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{t("chat.delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</li>
);
})}
</ul>
</section>
))}
</div>
</ScrollArea> </ScrollArea>
); );
} }
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<string, ChatSummary[]>();
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);
}

View File

@ -79,20 +79,8 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) {
<section className="flex min-h-0 flex-1 flex-col"> <section className="flex min-h-0 flex-1 flex-col">
<div className="flex flex-1 flex-col items-center justify-center gap-8 px-4 pb-6"> <div className="flex flex-1 flex-col items-center justify-center gap-8 px-4 pb-6">
<div className="flex flex-col items-center gap-4 animate-in fade-in-0 slide-in-from-bottom-2 duration-500"> <div className="flex flex-col items-center gap-4 animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<picture>
<source
srcSet="/brand/nanobot_logo.webp"
type="image/webp"
/>
<img
src="/brand/nanobot_logo.png"
alt="nanobot"
className="h-12 w-auto select-none drop-shadow-sm"
draggable={false}
/>
</picture>
<h1 className="text-xl font-medium tracking-tight text-foreground/90"> <h1 className="text-xl font-medium tracking-tight text-foreground/90">
What's on your mind? What can I do for you?
</h1> </h1>
<p className="max-w-md text-center text-sm text-muted-foreground"> <p className="max-w-md text-center text-sm text-muted-foreground">
Your conversations are persisted locally under the nanobot Your conversations are persisted locally under the nanobot
@ -105,7 +93,7 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) {
disabled={booting} disabled={booting}
onSend={handleWelcomeSend} onSend={handleWelcomeSend}
placeholder={ placeholder={
booting ? "Opening a new chat…" : "Type your message…" booting ? "Opening a new chat…" : "Ask anything..."
} }
/> />
</div> </div>

View File

@ -6,21 +6,21 @@ import { useClient } from "@/providers/ClientProvider";
import type { ConnectionStatus } from "@/lib/types"; import type { ConnectionStatus } from "@/lib/types";
const COPY: Record<ConnectionStatus, { color: string }> = { const COPY: Record<ConnectionStatus, { color: string }> = {
idle: { color: "bg-card/40 text-muted-foreground" }, idle: { color: "text-muted-foreground" },
connecting: { connecting: {
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300", color: "text-amber-700 dark:text-amber-300",
}, },
open: { open: {
color: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400", color: "text-emerald-700 dark:text-emerald-400",
}, },
reconnecting: { reconnecting: {
color: "bg-amber-500/10 text-amber-700 dark:text-amber-300", color: "text-amber-700 dark:text-amber-300",
}, },
closed: { closed: {
color: "bg-card/40 text-muted-foreground", color: "text-muted-foreground",
}, },
error: { error: {
color: "bg-destructive/10 text-destructive", color: "text-destructive",
}, },
}; };
@ -39,7 +39,7 @@ export function ConnectionBadge() {
return ( return (
<span <span
className={cn( className={cn(
"inline-flex items-center gap-1.5 rounded-md border border-border/60 px-2 py-1 text-[11px] font-medium transition-colors", "inline-flex min-w-0 items-center gap-1.5 rounded-md px-1.5 py-1 text-[11px] font-medium transition-colors",
meta.color, meta.color,
)} )}
aria-live="polite" aria-live="polite"

View File

@ -1,5 +1,5 @@
import { useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { ChevronRight, FileIcon, ImageIcon, PlaySquare, Wrench } from "lucide-react"; import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Wrench } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { ImageLightbox } from "@/components/ImageLightbox"; import { ImageLightbox } from "@/components/ImageLightbox";
@ -21,8 +21,33 @@ interface MessageBubbleProps {
* collapsible group so intermediate steps never masquerade as replies. * collapsible group so intermediate steps never masquerade as replies.
*/ */
export function MessageBubble({ message }: MessageBubbleProps) { export function MessageBubble({ message }: MessageBubbleProps) {
const { t } = useTranslation();
const [copied, setCopied] = useState(false);
const copyResetRef = useRef<number | null>(null);
const baseAnim = "animate-in fade-in-0 slide-in-from-bottom-1 duration-300"; 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") { if (message.kind === "trace") {
return <TraceGroup message={message} animClass={baseAnim} />; return <TraceGroup message={message} animClass={baseAnim} />;
} }
@ -60,6 +85,7 @@ export function MessageBubble({ message }: MessageBubbleProps) {
const empty = message.content.trim().length === 0; const empty = message.content.trim().length === 0;
const media = message.media ?? []; const media = message.media ?? [];
const showAssistantActions = message.role === "assistant" && !message.isStreaming && !empty;
return ( return (
<div className={cn("w-full text-sm", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}> <div className={cn("w-full text-sm", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
{empty && message.isStreaming ? ( {empty && message.isStreaming ? (
@ -69,6 +95,27 @@ export function MessageBubble({ message }: MessageBubbleProps) {
<MarkdownText>{message.content}</MarkdownText> <MarkdownText>{message.content}</MarkdownText>
{message.isStreaming && <StreamCursor />} {message.isStreaming && <StreamCursor />}
{media.length > 0 ? <MessageMedia media={media} align="left" /> : null} {media.length > 0 ? <MessageMedia media={media} align="left" /> : null}
{showAssistantActions ? (
<div className="mt-2 flex items-center gap-1 text-muted-foreground">
<button
type="button"
onClick={onCopyAssistantReply}
aria-label={copied ? t("message.copiedReply") : t("message.copyReply")}
title={copied ? t("message.copiedReply") : t("message.copyReply")}
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-full",
"transition-colors hover:bg-muted/55 hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
>
{copied ? (
<Check className="h-4 w-4" aria-hidden />
) : (
<Copy className="h-4 w-4" aria-hidden />
)}
</button>
</div>
) : null}
</> </>
)} )}
</div> </div>

View File

@ -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 { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList"; import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge"; import { ConnectionBadge } from "@/components/ConnectionBadge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator"; import { Separator } from "@/components/ui/separator";
import { cn } from "@/lib/utils";
import type { ChatSummary } from "@/lib/types"; import type { ChatSummary } from "@/lib/types";
interface SidebarProps { interface SidebarProps {
sessions: ChatSummary[]; sessions: ChatSummary[];
activeKey: string | null; activeKey: string | null;
loading: boolean; loading: boolean;
theme: "light" | "dark";
onToggleTheme: () => void;
onNewChat: () => void; onNewChat: () => void;
onSelect: (key: string) => void; onSelect: (key: string) => void;
onRefresh: () => void;
onRequestDelete: (key: string, label: string) => void; onRequestDelete: (key: string, label: string) => void;
onCollapse: () => void; onCollapse: () => void;
activeView?: "chat" | "settings";
onOpenSettings: () => void;
} }
export function Sidebar(props: SidebarProps) { export function Sidebar(props: SidebarProps) {
const { t } = useTranslation(); 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 ( return (
<aside className="flex h-full w-full flex-col border-r border-sidebar-border/70 bg-sidebar text-sidebar-foreground"> <nav
<div className="flex items-center justify-between px-3 pb-2 pt-3"> aria-label={t("sidebar.navigation")}
className="flex h-full w-full flex-col border-r border-sidebar-border/60 bg-sidebar text-sidebar-foreground"
>
<div className="flex items-center justify-between px-3 pb-2.5 pt-3">
<picture className="block min-w-0"> <picture className="block min-w-0">
<source srcSet="/brand/nanobot_logo.webp" type="image/webp" /> <source srcSet="/brand/nanobot_logo.webp" type="image/webp" />
<img <img
src="/brand/nanobot_logo.png" src="/brand/nanobot_logo.png"
alt="nanobot" alt="nanobot"
className="h-7 w-auto select-none object-contain" className="h-6 w-auto select-none object-contain opacity-95"
draggable={false} draggable={false}
/> />
</picture> </picture>
<div className="flex items-center gap-0.5"> <Button
<Button variant="ghost"
variant="ghost" size="icon"
size="icon" aria-label={t("sidebar.collapse")}
aria-label={t("sidebar.toggleTheme")} onClick={props.onCollapse}
onClick={props.onToggleTheme} className="h-7 w-7 rounded-lg text-muted-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground" >
> <PanelLeftClose className="h-3.5 w-3.5" />
{props.theme === "dark" ? ( </Button>
<Sun className="h-3.5 w-3.5" />
) : (
<Moon className="h-3.5 w-3.5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("sidebar.collapse")}
onClick={props.onCollapse}
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
<PanelLeftClose className="h-3.5 w-3.5" />
</Button>
</div>
</div> </div>
<div className="px-2 pb-2">
<div className="space-y-1.5 px-2 pb-2">
<label className="relative block">
<span className="sr-only">{t("sidebar.searchAria")}</span>
<Search
className="pointer-events-none absolute left-3 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground/70"
aria-hidden
/>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder={t("sidebar.searchPlaceholder")}
aria-label={t("sidebar.searchAria")}
className={cn(
"h-8 w-full rounded-full border border-transparent bg-sidebar-accent/45",
"pl-8 pr-3 text-[12.5px] text-sidebar-foreground outline-none",
"placeholder:text-muted-foreground/75",
"transition-colors hover:bg-sidebar-accent/65",
"focus:border-sidebar-border/80 focus:bg-sidebar-accent/70",
"focus:ring-1 focus:ring-sidebar-border/70",
)}
/>
</label>
<Button <Button
onClick={props.onNewChat} onClick={props.onNewChat}
className="h-9 w-full justify-start gap-2 rounded-full px-3 text-[13px] font-medium text-sidebar-foreground/90 hover:bg-sidebar-accent hover:text-sidebar-foreground" className="h-8 w-full justify-start gap-2 rounded-full px-3 text-[12.5px] font-medium text-sidebar-foreground/92 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
variant="ghost" variant="ghost"
> >
<SquarePen className="h-3.5 w-3.5" /> <SquarePen className="h-3.5 w-3.5" />
{t("sidebar.newChat")} {t("sidebar.newChat")}
</Button> </Button>
</div> </div>
<div className="flex items-center justify-between px-3 pb-1.5 pt-2.5 text-[11px] font-medium text-muted-foreground">
<span>{t("sidebar.recent")}</span>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 rounded-md text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
onClick={props.onRefresh}
aria-label={t("sidebar.refreshSessions")}
>
<RefreshCcw className="h-3.5 w-3.5" />
</Button>
</div>
<div className="flex-1 overflow-hidden"> <div className="flex-1 overflow-hidden">
<ChatList <ChatList
sessions={props.sessions} sessions={filteredSessions}
activeKey={props.activeKey} activeKey={props.activeKey}
loading={props.loading} loading={props.loading}
emptyLabel={
normalizedQuery ? t("sidebar.noSearchResults") : t("chat.noSessions")
}
onSelect={props.onSelect} onSelect={props.onSelect}
onRequestDelete={props.onRequestDelete} onRequestDelete={props.onRequestDelete}
/> />
</div> </div>
<Separator className="bg-sidebar-border/50" /> <Separator className="bg-sidebar-border/50" />
<div className="flex items-center justify-between gap-2 px-2.5 py-2 text-xs"> <div className="flex items-center px-2.5 py-2.5 text-xs">
<ConnectionBadge /> <ConnectionBadge />
<Button
onClick={props.onOpenSettings}
className="h-7 gap-1.5 rounded-md px-2 text-[11px] text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
variant={props.activeView === "settings" ? "secondary" : "ghost"}
>
<Settings className="h-3.5 w-3.5" />
Settings
</Button>
</div> </div>
</aside> </nav>
); );
} }

View File

@ -10,7 +10,7 @@ import {
ArrowUp, ArrowUp,
ImageIcon, ImageIcon,
Loader2, Loader2,
Paperclip, Plus,
X, X,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -219,8 +219,8 @@ export function ThreadComposer({
className={cn( className={cn(
"relative mx-auto flex w-full flex-col overflow-hidden transition-all duration-200", "relative mx-auto flex w-full flex-col overflow-hidden transition-all duration-200",
isHero isHero
? "max-w-[40rem] rounded-[24px] border border-border/75 bg-card shadow-[0_10px_30px_rgba(0,0,0,0.10)]" ? "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-[16px] border border-border/70 bg-card", : "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", "focus-within:ring-1 focus-within:ring-foreground/8",
disabled && "opacity-60", disabled && "opacity-60",
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary", isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
@ -268,9 +268,9 @@ export function ThreadComposer({
className={cn( className={cn(
"w-full resize-none bg-transparent", "w-full resize-none bg-transparent",
isHero 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", : "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", "focus:outline-none focus-visible:outline-none",
"disabled:cursor-not-allowed", "disabled:cursor-not-allowed",
)} )}
@ -289,7 +289,7 @@ export function ThreadComposer({
<div <div
className={cn( className={cn(
"flex items-center justify-between gap-2", "flex items-center justify-between gap-2",
isHero ? "px-3.5 pb-3.5" : "px-3 pb-2", isHero ? "px-4 pb-4" : "px-3 pb-2",
)} )}
> >
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 items-center gap-2">
@ -310,10 +310,12 @@ export function ThreadComposer({
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
className={cn( className={cn(
"rounded-full text-muted-foreground hover:text-foreground", "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",
)} )}
> >
<Paperclip className={cn(isHero ? "h-4 w-4" : "h-3.5 w-3.5")} /> <Plus className={cn(isHero ? "h-5 w-5" : "h-4 w-4")} />
</Button> </Button>
{modelLabel ? ( {modelLabel ? (
<span <span
@ -321,7 +323,9 @@ export function ThreadComposer({
className={cn( className={cn(
"inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1", "inline-flex min-w-0 items-center gap-1.5 rounded-full border px-2.5 py-1",
"border-foreground/10 bg-foreground/[0.035] font-medium text-foreground/80", "border-foreground/10 bg-foreground/[0.035] font-medium text-foreground/80",
isHero ? "text-[11px]" : "text-[10.5px]", isHero
? "max-w-[13rem] text-[12px] shadow-[0_2px_8px_rgba(15,23,42,0.04)]"
: "max-w-[10rem] text-[10.5px] shadow-[0_2px_8px_rgba(15,23,42,0.035)]",
)} )}
> >
<span <span
@ -331,19 +335,23 @@ export function ThreadComposer({
<span className="truncate">{modelLabel}</span> <span className="truncate">{modelLabel}</span>
</span> </span>
) : null} ) : null}
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline"> {!isHero ? (
{t("thread.composer.sendHint")} <span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
</span> {t("thread.composer.sendHint")}
</span>
) : null}
</div> </div>
<span className="sm:hidden" aria-hidden /> <span className={cn(isHero ? "hidden" : "sm:hidden")} aria-hidden />
<Button <Button
type="submit" type="submit"
size="icon" size="icon"
disabled={!canSend} disabled={!canSend}
aria-label={t("thread.composer.send")} aria-label={t("thread.composer.send")}
className={cn( className={cn(
"rounded-full border border-border/70 bg-secondary/85 text-secondary-foreground shadow-none transition-transform hover:bg-accent", isHero
isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5", ? "h-9 w-9 rounded-full border border-foreground bg-foreground text-background shadow-[0_4px_12px_rgba(15,23,42,0.20)] hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80"
: "rounded-full border border-foreground bg-foreground text-background shadow-[0_3px_10px_rgba(15,23,42,0.18)] transition-transform hover:bg-foreground/90 disabled:border-foreground/35 disabled:bg-foreground/35 disabled:text-background/80",
isHero ? "" : "h-7.5 w-7.5",
canSend && "hover:scale-[1.03] active:scale-95", canSend && "hover:scale-[1.03] active:scale-95",
)} )}
> >

View File

@ -1,4 +1,4 @@
import { PanelLeftOpen } from "lucide-react"; import { Menu, Moon, PanelLeftOpen, Settings, Sun } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@ -7,17 +7,66 @@ import { cn } from "@/lib/utils";
interface ThreadHeaderProps { interface ThreadHeaderProps {
title: string; title: string;
onToggleSidebar: () => void; onToggleSidebar: () => void;
onGoHome: () => void; theme: "light" | "dark";
onToggleTheme: () => void;
onOpenSettings: () => void;
hideSidebarToggleOnDesktop?: boolean; hideSidebarToggleOnDesktop?: boolean;
minimal?: boolean;
} }
export function ThreadHeader({ export function ThreadHeader({
title, title,
onToggleSidebar, onToggleSidebar,
onGoHome, theme,
onToggleTheme,
onOpenSettings,
hideSidebarToggleOnDesktop = false, hideSidebarToggleOnDesktop = false,
minimal = false,
}: ThreadHeaderProps) { }: ThreadHeaderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
if (minimal) {
return (
<div className="relative z-10 flex h-11 items-center justify-between gap-3 px-3 py-2">
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleSidebar")}
onClick={onToggleSidebar}
className={cn(
"h-7 w-7 rounded-md text-muted-foreground hover:bg-accent/35 hover:text-foreground",
hideSidebarToggleOnDesktop && "lg:pointer-events-none lg:opacity-0",
)}
>
<Menu className="h-3.5 w-3.5" />
</Button>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleTheme")}
onClick={onToggleTheme}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.settings")}
onClick={onOpenSettings}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
<Settings className="h-4 w-4" />
</Button>
</div>
</div>
);
}
return ( return (
<div className="relative z-10 flex items-center justify-between gap-3 px-3 py-2"> <div className="relative z-10 flex items-center justify-between gap-3 px-3 py-2">
<div className="relative flex min-w-0 items-center gap-2"> <div className="relative flex min-w-0 items-center gap-2">
@ -33,19 +82,34 @@ export function ThreadHeader({
> >
<PanelLeftOpen className="h-3.5 w-3.5" /> <PanelLeftOpen className="h-3.5 w-3.5" />
</Button> </Button>
<button <div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
type="button"
onClick={onGoHome}
className="flex min-w-0 items-center gap-2 rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent/35 hover:text-foreground"
>
<img
src="/brand/nanobot_icon.png"
alt=""
className="h-4 w-4 rounded-[5px] opacity-85"
aria-hidden
/>
<span className="max-w-[min(60vw,32rem)] truncate">{title}</span> <span className="max-w-[min(60vw,32rem)] truncate">{title}</span>
</button> </div>
</div>
<div className="flex items-center gap-0.5">
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.toggleTheme")}
onClick={onToggleTheme}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
{theme === "dark" ? (
<Sun className="h-4 w-4" />
) : (
<Moon className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.settings")}
onClick={onOpenSettings}
className="h-8 w-8 rounded-full text-muted-foreground/85 hover:bg-accent/40 hover:text-foreground"
>
<Settings className="h-4 w-4" />
</Button>
</div> </div>
<div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" /> <div aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />

View File

@ -1,4 +1,13 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 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 { useTranslation } from "react-i18next";
import { AskUserPrompt } from "@/components/thread/AskUserPrompt"; import { AskUserPrompt } from "@/components/thread/AskUserPrompt";
@ -15,8 +24,13 @@ interface ThreadShellProps {
session: ChatSummary | null; session: ChatSummary | null;
title: string; title: string;
onToggleSidebar: () => void; onToggleSidebar: () => void;
onGoHome: () => void; onGoHome?: () => void;
onNewChat: () => Promise<string | null>; onNewChat?: () => void;
onCreateChat?: () => Promise<string | null>;
onTurnEnd?: () => void;
theme?: "light" | "dark";
onToggleTheme?: () => void;
onOpenSettings?: () => void;
hideSidebarToggleOnDesktop?: boolean; hideSidebarToggleOnDesktop?: boolean;
} }
@ -28,12 +42,24 @@ function toModelBadgeLabel(modelName: string | null): string | null {
return leaf || trimmed; 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({ export function ThreadShell({
session, session,
title, title,
onToggleSidebar, onToggleSidebar,
onGoHome, onCreateChat,
onNewChat, onTurnEnd,
theme = "light",
onToggleTheme = () => {},
onOpenSettings = () => {},
hideSidebarToggleOnDesktop = false, hideSidebarToggleOnDesktop = false,
}: ThreadShellProps) { }: ThreadShellProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@ -57,7 +83,7 @@ export function ThreadShell({
setMessages, setMessages,
streamError, streamError,
dismissStreamError, dismissStreamError,
} = useNanobotStream(chatId, initial, hasPendingToolCalls); } = useNanobotStream(chatId, initial, hasPendingToolCalls, onTurnEnd);
const showHeroComposer = messages.length === 0 && !loading; const showHeroComposer = messages.length === 0 && !loading;
const pendingAsk = useMemo(() => { const pendingAsk = useMemo(() => {
for (let index = messages.length - 1; index >= 0; index -= 1) { for (let index = messages.length - 1; index >= 0; index -= 1) {
@ -125,13 +151,94 @@ export function ThreadShell({
if (booting) return; if (booting) return;
setBooting(true); setBooting(true);
pendingFirstRef.current = content; pendingFirstRef.current = content;
const newId = await onNewChat(); const newId = await onCreateChat?.();
if (!newId) { if (!newId) {
pendingFirstRef.current = null; pendingFirstRef.current = null;
setBooting(false); setBooting(false);
} }
}, },
[booting, onNewChat], [booting, onCreateChat],
);
const handleQuickAction = useCallback(
(prompt: string) => {
if (session) {
send(prompt);
return;
}
void handleWelcomeSend(prompt);
},
[handleWelcomeSend, send, session],
);
const quickActions = (
<div className="mx-auto grid w-full max-w-[58rem] grid-cols-2 gap-3 pt-4 sm:grid-cols-3 lg:grid-cols-6 lg:gap-4">
{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 (
<button
key={key}
type="button"
onClick={() => handleQuickAction(prompt)}
disabled={booting || isStreaming}
className="group flex min-h-[136px] flex-col justify-between rounded-[20px] border border-black/[0.035] bg-card px-5 py-5 text-left shadow-[0_14px_34px_rgba(15,23,42,0.07)] transition-all hover:-translate-y-0.5 hover:shadow-[0_18px_42px_rgba(15,23,42,0.10)] disabled:pointer-events-none disabled:opacity-60 dark:border-white/[0.06] dark:shadow-[0_16px_34px_rgba(0,0,0,0.28)]"
>
<Icon className={`h-[18px] w-[18px] ${tone}`} strokeWidth={2} />
<span className="max-w-[7.5rem] text-[15px] font-medium leading-[1.28] tracking-[-0.01em] text-foreground/82">
{title}
</span>
<ChevronRight className="h-4 w-4 self-end text-muted-foreground/45 transition-colors group-hover:text-muted-foreground" />
</button>
);
})}
</div>
);
const composer = (
<>
{streamError ? (
<StreamErrorNotice
error={streamError}
onDismiss={dismissStreamError}
/>
) : null}
{pendingAsk ? (
<AskUserPrompt
question={pendingAsk.question}
buttons={pendingAsk.buttons}
onAnswer={send}
/>
) : null}
{session ? (
<ThreadComposer
onSend={send}
disabled={!chatId}
isStreaming={isStreaming}
placeholder={
showHeroComposer
? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread")
}
modelLabel={toModelBadgeLabel(modelName)}
variant={showHeroComposer ? "hero" : "thread"}
/>
) : (
<ThreadComposer
onSend={handleWelcomeSend}
disabled={booting}
isStreaming={isStreaming}
placeholder={
booting
? t("thread.composer.placeholderOpening")
: t("thread.composer.placeholderHero")
}
modelLabel={toModelBadgeLabel(modelName)}
variant="hero"
/>
)}
{showHeroComposer ? quickActions : null}
</>
); );
const emptyState = loading ? ( const emptyState = loading ? (
@ -139,20 +246,10 @@ export function ThreadShell({
{t("thread.loadingConversation")} {t("thread.loadingConversation")}
</div> </div>
) : ( ) : (
<div className="flex w-full max-w-[40rem] flex-col gap-2 text-left 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">
<div className="inline-flex items-center gap-2 text-[11px] font-medium text-muted-foreground"> <h1 className="text-balance text-[40px] font-normal leading-tight tracking-[-0.045em] text-foreground sm:text-[48px]">
<img {t("thread.empty.greeting")}
src="/brand/nanobot_icon.png" </h1>
alt=""
aria-hidden
draggable={false}
className="h-4 w-4 rounded-sm opacity-90"
/>
<span className="text-foreground/82">nanobot</span>
</div>
<p className="max-w-[28rem] text-[13px] leading-6 text-muted-foreground">
{t("thread.empty.description")}
</p>
</div> </div>
); );
@ -161,57 +258,17 @@ export function ThreadShell({
<ThreadHeader <ThreadHeader
title={title} title={title}
onToggleSidebar={onToggleSidebar} onToggleSidebar={onToggleSidebar}
onGoHome={onGoHome} theme={theme}
onToggleTheme={onToggleTheme}
onOpenSettings={onOpenSettings}
hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop} hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop}
minimal={!session && !loading}
/> />
<ThreadViewport <ThreadViewport
messages={messages} messages={messages}
isStreaming={isStreaming} isStreaming={isStreaming}
emptyState={emptyState} emptyState={emptyState}
composer={ composer={composer}
<>
{streamError ? (
<StreamErrorNotice
error={streamError}
onDismiss={dismissStreamError}
/>
) : null}
{pendingAsk ? (
<AskUserPrompt
question={pendingAsk.question}
buttons={pendingAsk.buttons}
onAnswer={send}
/>
) : null}
{session ? (
<ThreadComposer
onSend={send}
disabled={!chatId}
isStreaming={isStreaming}
placeholder={
showHeroComposer
? t("thread.composer.placeholderHero")
: t("thread.composer.placeholderThread")
}
modelLabel={toModelBadgeLabel(modelName)}
variant={showHeroComposer ? "hero" : "thread"}
/>
) : (
<ThreadComposer
onSend={handleWelcomeSend}
disabled={booting}
isStreaming={isStreaming}
placeholder={
booting
? t("thread.composer.placeholderOpening")
: t("thread.composer.placeholderHero")
}
modelLabel={toModelBadgeLabel(modelName)}
variant="hero"
/>
)}
</>
}
/> />
</section> </section>
); );

View File

@ -82,9 +82,9 @@ export function ThreadViewport({
</div> </div>
</div> </div>
) : ( ) : (
<div className="mx-auto flex min-h-full w-full max-w-[64rem] flex-col px-4"> <div className="mx-auto flex min-h-full w-full max-w-[72rem] flex-col px-4">
<div className="flex w-full flex-1 justify-center pb-16 pt-14 md:pt-[3.5rem]"> <div className="flex w-full flex-1 items-center justify-center pb-[7vh] pt-8">
<div className="flex w-full max-w-[40rem] flex-col gap-5"> <div className="flex w-full max-w-[58rem] flex-col gap-6">
{emptyState} {emptyState}
<div className="w-full">{composer}</div> <div className="w-full">{composer}</div>
</div> </div>

View File

@ -25,9 +25,9 @@
--input: 0 0% 89.8%; --input: 0 0% 89.8%;
--ring: 0 0% 3.9%; --ring: 0 0% 3.9%;
--radius: 0.4375rem; --radius: 0.4375rem;
--sidebar: 0 0% 98%; --sidebar: 0 0% 98.5%;
--sidebar-foreground: 0 0% 3.9%; --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-accent-foreground: 0 0% 9%;
--sidebar-border: 0 0% 89.8%; --sidebar-border: 0 0% 89.8%;
} }
@ -52,9 +52,9 @@
--border: 0 0% 18%; --border: 0 0% 18%;
--input: 0 0% 18%; --input: 0 0% 18%;
--ring: 0 0% 83.1%; --ring: 0 0% 83.1%;
--sidebar: 0 0% 12%; --sidebar: 0 0% 11.5%;
--sidebar-foreground: 0 0% 98%; --sidebar-foreground: 0 0% 98%;
--sidebar-accent: 0 0% 16%; --sidebar-accent: 0 0% 15.5%;
--sidebar-accent-foreground: 0 0% 98%; --sidebar-accent-foreground: 0 0% 98%;
--sidebar-border: 0 0% 18%; --sidebar-border: 0 0% 18%;
} }

View File

@ -38,6 +38,7 @@ export function useNanobotStream(
chatId: string | null, chatId: string | null,
initialMessages: UIMessage[] = [], initialMessages: UIMessage[] = [],
hasPendingToolCalls = false, hasPendingToolCalls = false,
onTurnEnd?: () => void,
): { ): {
messages: UIMessage[]; messages: UIMessage[];
isStreaming: boolean; isStreaming: boolean;
@ -159,6 +160,12 @@ export function useNanobotStream(
setMessages((prev) => setMessages((prev) =>
prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)), prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m)),
); );
onTurnEnd?.();
return;
}
if (ev.event === "session_updated") {
onTurnEnd?.();
return; return;
} }
@ -233,7 +240,7 @@ export function useNanobotStream(
streamEndTimerRef.current = null; streamEndTimerRef.current = null;
} }
}; };
}, [chatId, client]); }, [chatId, client, onTurnEnd]);
const send = useCallback( const send = useCallback(
(content: string, images?: SendImage[]) => { (content: string, images?: SendImage[]) => {

View File

@ -61,6 +61,7 @@ export function useSessions(): {
chatId, chatId,
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
title: "",
preview: "", preview: "",
}, },
...prev.filter((s) => s.key !== key), ...prev.filter((s) => s.key !== key),
@ -221,7 +222,7 @@ export function sessionTitle(
firstUserMessage?: string, firstUserMessage?: string,
): string { ): string {
return deriveTitle( return deriveTitle(
firstUserMessage || session.preview, session.title || firstUserMessage || session.preview,
i18n.t("chat.newChat"), i18n.t("chat.newChat"),
); );
} }

View File

@ -18,11 +18,19 @@
} }
}, },
"sidebar": { "sidebar": {
"navigation": "Sidebar navigation",
"globalActions": "Global actions",
"collapse": "Collapse sidebar", "collapse": "Collapse sidebar",
"toggleTheme": "Toggle theme", "toggleTheme": "Toggle theme",
"home": "Home",
"newChat": "New chat", "newChat": "New chat",
"searchAria": "Search chats",
"searchPlaceholder": "Search chats",
"searchResults": "Results",
"noSearchResults": "No matching chats.",
"recent": "Recent", "recent": "Recent",
"refreshSessions": "Refresh sessions", "refreshSessions": "Refresh sessions",
"settings": "Settings",
"language": { "language": {
"label": "Language", "label": "Language",
"ariaLabel": "Change language" "ariaLabel": "Change language"
@ -34,7 +42,12 @@
"noSessions": "No sessions yet.", "noSessions": "No sessions yet.",
"actions": "Chat actions for {{title}}", "actions": "Chat actions for {{title}}",
"delete": "Delete", "delete": "Delete",
"newChat": "New chat" "newChat": "New chat",
"groups": {
"today": "Today",
"yesterday": "Yesterday",
"earlier": "Earlier"
}
}, },
"deleteConfirm": { "deleteConfirm": {
"title": "Delete “{{title}}”?", "title": "Delete “{{title}}”?",
@ -53,20 +66,55 @@
"thread": { "thread": {
"loadingConversation": "Loading conversation…", "loadingConversation": "Loading conversation…",
"empty": { "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": { "header": {
"toggleSidebar": "Toggle sidebar" "toggleSidebar": "Toggle sidebar",
"newChat": "Start a new chat",
"toggleTheme": "Toggle theme from header",
"settings": "Open settings"
}, },
"composer": { "composer": {
"placeholderThread": "Type your message…", "placeholderThread": "Type your message…",
"placeholderHero": "What's on your mind?", "placeholderHero": "Ask anything...",
"placeholderOpening": "Opening a new chat…", "placeholderOpening": "Opening a new chat…",
"placeholderStreaming": "Model is responding…", "placeholderStreaming": "Model is responding…",
"inputAria": "Message input", "inputAria": "Message input",
"sendHint": "Enter to send · Shift+Enter for newline", "sendHint": "Enter to send · Shift+Enter for newline",
"send": "Send message", "send": "Send message",
"attachImage": "Attach image", "attachImage": "Attach image",
"tools": {
"search": "Search",
"reason": "Reason",
"deepResearch": "Deep research",
"voice": "Voice input"
},
"encoding": "Encoding…", "encoding": "Encoding…",
"remove": "Remove attachment", "remove": "Remove attachment",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)", "normalizedSizeHint": "{{orig}} → {{current}} (auto)",
@ -86,7 +134,9 @@
"assistantTyping": "Assistant is typing", "assistantTyping": "Assistant is typing",
"toolSingle": "Using a tool", "toolSingle": "Using a tool",
"toolMany": "Used {{count}} tools", "toolMany": "Used {{count}} tools",
"imageAttachment": "Image attachment" "imageAttachment": "Image attachment",
"copyReply": "Copy reply",
"copiedReply": "Copied reply"
}, },
"lightbox": { "lightbox": {
"title": "Image preview", "title": "Image preview",

View File

@ -18,11 +18,19 @@
} }
}, },
"sidebar": { "sidebar": {
"navigation": "侧边栏导航",
"globalActions": "全局操作",
"collapse": "收起侧边栏", "collapse": "收起侧边栏",
"toggleTheme": "切换主题", "toggleTheme": "切换主题",
"home": "首页",
"newChat": "新建对话", "newChat": "新建对话",
"searchAria": "搜索会话",
"searchPlaceholder": "搜索会话",
"searchResults": "搜索结果",
"noSearchResults": "没有匹配的会话。",
"recent": "最近对话", "recent": "最近对话",
"refreshSessions": "刷新会话", "refreshSessions": "刷新会话",
"settings": "设置",
"language": { "language": {
"label": "语言", "label": "语言",
"ariaLabel": "切换语言" "ariaLabel": "切换语言"
@ -34,7 +42,12 @@
"noSessions": "还没有会话。", "noSessions": "还没有会话。",
"actions": "“{{title}}” 的会话操作", "actions": "“{{title}}” 的会话操作",
"delete": "删除", "delete": "删除",
"newChat": "新建对话" "newChat": "新建对话",
"groups": {
"today": "今天",
"yesterday": "昨天",
"earlier": "更早"
}
}, },
"deleteConfirm": { "deleteConfirm": {
"title": "删除“{{title}}”?", "title": "删除“{{title}}”?",
@ -53,20 +66,55 @@
"thread": { "thread": {
"loadingConversation": "正在加载对话…", "loadingConversation": "正在加载对话…",
"empty": { "empty": {
"description": "可以提问、继续本地工作,或者开启一个新线程。" "greeting": "我可以帮你做什么?",
"quickActions": {
"plan": {
"title": "创建项目计划",
"prompt": "帮我为接下来要做的事情写一份简洁的项目计划。"
},
"analyze": {
"title": "分析这些数据",
"prompt": "帮我分析这些数据,并指出最重要的模式。"
},
"brainstorm": {
"title": "头脑风暴想法",
"prompt": "围绕这个问题头脑风暴几个实用方案,并说明取舍。"
},
"code": {
"title": "编写代码",
"prompt": "帮我为这个任务写代码,先从最小可用改动开始。"
},
"summarize": {
"title": "总结这份文档",
"prompt": "帮我总结这份文档,并列出关键要点。"
},
"more": {
"title": "更多",
"prompt": "展示几个你在这个工作区里可以帮我的实用方式。"
}
}
}, },
"header": { "header": {
"toggleSidebar": "切换侧边栏" "toggleSidebar": "切换侧边栏",
"newChat": "从顶部新建对话",
"toggleTheme": "从顶部切换主题",
"settings": "打开设置"
}, },
"composer": { "composer": {
"placeholderThread": "输入消息…", "placeholderThread": "输入消息…",
"placeholderHero": "你在想什么?", "placeholderHero": "问任何问题...",
"placeholderOpening": "正在打开新对话…", "placeholderOpening": "正在打开新对话…",
"placeholderStreaming": "模型正在回复…", "placeholderStreaming": "模型正在回复…",
"inputAria": "消息输入框", "inputAria": "消息输入框",
"sendHint": "Enter 发送 · Shift+Enter 换行", "sendHint": "Enter 发送 · Shift+Enter 换行",
"send": "发送消息", "send": "发送消息",
"attachImage": "添加图片", "attachImage": "添加图片",
"tools": {
"search": "搜索",
"reason": "推理",
"deepResearch": "深度研究",
"voice": "语音输入"
},
"encoding": "处理中…", "encoding": "处理中…",
"remove": "移除附件", "remove": "移除附件",
"normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)", "normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)",
@ -86,7 +134,9 @@
"assistantTyping": "助手正在输入", "assistantTyping": "助手正在输入",
"toolSingle": "正在使用工具", "toolSingle": "正在使用工具",
"toolMany": "已使用 {{count}} 个工具", "toolMany": "已使用 {{count}} 个工具",
"imageAttachment": "图片附件" "imageAttachment": "图片附件",
"copyReply": "复制回复",
"copiedReply": "已复制回复"
}, },
"lightbox": { "lightbox": {
"title": "图片预览", "title": "图片预览",

View File

@ -42,6 +42,7 @@ export async function listSessions(
key: string; key: string;
created_at: string | null; created_at: string | null;
updated_at: string | null; updated_at: string | null;
title?: string;
preview?: string; preview?: string;
}; };
const body = await request<{ sessions: Row[] }>( const body = await request<{ sessions: Row[] }>(
@ -53,6 +54,7 @@ export async function listSessions(
...splitKey(s.key), ...splitKey(s.key),
createdAt: s.created_at, createdAt: s.created_at,
updatedAt: s.updated_at, updatedAt: s.updated_at,
title: s.title ?? "",
preview: s.preview ?? "", preview: s.preview ?? "",
})); }));
} }

View File

@ -185,8 +185,8 @@ export class NanobotClient {
this.knownChats.add(chatId); this.knownChats.add(chatId);
const frame: Outbound = const frame: Outbound =
media && media.length > 0 media && media.length > 0
? { type: "message", chat_id: chatId, content, media } ? { type: "message", chat_id: chatId, content, media, webui: true }
: { type: "message", chat_id: chatId, content }; : { type: "message", chat_id: chatId, content, webui: true };
this.queueSend(frame); this.queueSend(frame);
} }

View File

@ -56,6 +56,7 @@ export interface ChatSummary {
chatId: string; chatId: string;
createdAt: string | null; createdAt: string | null;
updatedAt: string | null; updatedAt: string | null;
title?: string;
preview: string; preview: string;
} }
@ -125,6 +126,7 @@ export type InboundEvent =
stream_id?: string; stream_id?: string;
} }
| { event: "turn_end"; chat_id: string } | { event: "turn_end"; chat_id: string }
| { event: "session_updated"; chat_id: string }
| { event: "error"; chat_id?: string; detail?: string }; | { event: "error"; chat_id?: string; detail?: string };
/** Base64-encoded image attached to an outbound ``message`` envelope. /** Base64-encoded image attached to an outbound ``message`` envelope.
@ -148,4 +150,7 @@ export type Outbound =
chat_id: string; chat_id: string;
content: string; content: string;
media?: OutboundMedia[]; media?: OutboundMedia[];
/** Marks messages sent by the embedded WebUI, without changing the
* generic websocket protocol for other clients. */
webui?: true;
}; };

View File

@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest"; 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", () => { describe("webui API helpers", () => {
beforeEach(() => { 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: "",
},
]);
});
}); });

View File

@ -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 { beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatSummary } from "@/lib/types"; import type { ChatSummary } from "@/lib/types";
@ -7,6 +7,7 @@ const connectSpy = vi.fn();
const refreshSpy = vi.fn(); const refreshSpy = vi.fn();
const createChatSpy = vi.fn().mockResolvedValue("chat-1"); const createChatSpy = vi.fn().mockResolvedValue("chat-1");
const deleteChatSpy = vi.fn(); const deleteChatSpy = vi.fn();
const toggleThemeSpy = vi.fn();
let mockSessions: ChatSummary[] = []; let mockSessions: ChatSummary[] = [];
vi.mock("@/hooks/useSessions", async (importOriginal) => { vi.mock("@/hooks/useSessions", async (importOriginal) => {
@ -34,7 +35,7 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
vi.mock("@/hooks/useTheme", () => ({ vi.mock("@/hooks/useTheme", () => ({
useTheme: () => ({ useTheme: () => ({
theme: "light" as const, theme: "light" as const,
toggle: vi.fn(), toggle: toggleThemeSpy,
}), }),
})); }));
@ -74,6 +75,7 @@ describe("App layout", () => {
refreshSpy.mockReset(); refreshSpy.mockReset();
createChatSpy.mockClear(); createChatSpy.mockClear();
deleteChatSpy.mockReset(); deleteChatSpy.mockReset();
toggleThemeSpy.mockReset();
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn().mockResolvedValue({ vi.fn().mockResolvedValue({
@ -121,8 +123,11 @@ describe("App layout", () => {
render(<App />); render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled()); await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
await waitFor(() => 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"), { fireEvent.pointerDown(screen.getByLabelText("Chat actions for First chat"), {
@ -140,14 +145,24 @@ describe("App layout", () => {
); );
await waitFor(() => await waitFor(() =>
expect( expect(
screen.getByRole("button", { name: /^Second chat$/ }), within(sidebar).getByRole("button", { name: /^Second chat$/ }),
).toBeInTheDocument(), ).toBeInTheDocument(),
); );
expect(screen.queryByText('Delete “First chat”?')).not.toBeInTheDocument(); expect(screen.queryByText('Delete “First chat”?')).not.toBeInTheDocument();
expect(document.body.style.pointerEvents).not.toBe("none"); expect(document.body.style.pointerEvents).not.toBe("none");
}, 15_000); }, 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( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async (input: RequestInfo | URL) => { vi.fn(async (input: RequestInfo | URL) => {
@ -180,10 +195,95 @@ describe("App layout", () => {
render(<App />); render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled()); 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(await screen.findByRole("heading", { name: "General" })).toBeInTheDocument();
expect(screen.getByText("AI")).toBeInTheDocument(); expect(screen.getByText("AI")).toBeInTheDocument();
expect(screen.getByDisplayValue("openai/gpt-4o")).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(<App />);
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(<App />);
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();
});
}); });

View File

@ -1,5 +1,5 @@
import { fireEvent, render, screen } from "@testing-library/react"; import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest"; import { describe, expect, it, vi } from "vitest";
import { MessageBubble } from "@/components/MessageBubble"; import { MessageBubble } from "@/components/MessageBubble";
import type { UIMessage } from "@/lib/types"; import type { UIMessage } from "@/lib/types";
@ -19,6 +19,44 @@ describe("MessageBubble", () => {
expect(row).toHaveClass("ml-auto", "flex"); expect(row).toHaveClass("ml-auto", "flex");
expect(pill).toHaveClass("ml-auto", "w-fit", "rounded-[18px]"); 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(<MessageBubble message={message} />);
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(<MessageBubble message={message} />);
expect(screen.queryByRole("button", { name: "Copy reply" })).not.toBeInTheDocument();
}); });
it("renders trace messages as collapsible tool groups", () => { it("renders trace messages as collapsible tool groups", () => {

View File

@ -116,7 +116,7 @@ describe("NanobotClient", () => {
// Attach is sent first because sendMessage adds to knownChats, which // Attach is sent first because sendMessage adds to knownChats, which
// handleOpen re-attaches; then the queued message follows. // handleOpen re-attaches; then the queued message follows.
expect(lastSocket().sent).toContain( 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", chat_id: "chat-x",
content: "look", content: "look",
media: [{ data_url: "data:image/png;base64,AAAA", name: "shot.png" }], media: [{ data_url: "data:image/png;base64,AAAA", name: "shot.png" }],
webui: true,
}); });
}); });
@ -214,6 +215,7 @@ describe("NanobotClient", () => {
type: "message", type: "message",
chat_id: "chat-x", chat_id: "chat-x",
content: "hello", content: "hello",
webui: true,
}); });
}); });

View File

@ -9,15 +9,38 @@ describe("ThreadComposer", () => {
<ThreadComposer <ThreadComposer
onSend={vi.fn()} onSend={vi.fn()}
modelLabel="claude-opus-4-5" modelLabel="claude-opus-4-5"
placeholder="What's on your mind?" placeholder="Ask anything..."
variant="hero" variant="hero"
/>, />,
); );
expect(screen.getByText("claude-opus-4-5")).toBeInTheDocument(); 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).toBeInTheDocument();
expect(input.className).toContain("min-h-[96px]"); expect(input.className).toContain("min-h-[78px]");
expect(input.parentElement?.className).toContain("max-w-[40rem]"); expect(input.parentElement?.className).toContain("max-w-[58rem]");
});
it("keeps the thread composer compact while matching the hero style", () => {
render(
<ThreadComposer
onSend={vi.fn()}
modelLabel="gpt-4o"
placeholder="Type your message..."
/>,
);
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");
}); });
}); });

View File

@ -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,
<ThreadShell
session={session("chat-title")}
title="Important conversation"
onToggleSidebar={() => {}}
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 () => { it("restores in-memory messages when switching away and back to a session", async () => {
const client = makeClient(); const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a"); const onNewChat = vi.fn().mockResolvedValue("chat-a");
@ -199,7 +219,67 @@ describe("ThreadShell", () => {
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText("delete me cleanly")).not.toBeInTheDocument(); 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,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
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,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
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 () => { 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(); expect(screen.queryByText("old answer")).not.toBeInTheDocument();
await waitFor(() => 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?"); const input = screen.getByPlaceholderText("Ask anything...");
expect(input.className).toContain("min-h-[96px]"); expect(input.className).toContain("min-h-[78px]");
expect(screen.queryByText("old answer")).not.toBeInTheDocument(); expect(screen.queryByText("old answer")).not.toBeInTheDocument();
}); });

View File

@ -159,7 +159,8 @@ describe("useNanobotStream", () => {
it("keeps streaming alive across stream_end and completes on turn_end", () => { it("keeps streaming alive across stream_end and completes on turn_end", () => {
const fake = fakeClient(); 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), wrapper: wrap(fake.client),
}); });
@ -211,5 +212,23 @@ describe("useNanobotStream", () => {
expect(result.current.isStreaming).toBe(false); expect(result.current.isStreaming).toBe(false);
expect(result.current.messages.every((message) => !message.isStreaming)).toBe(true); 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);
}); });
}); });