mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-19 16:12:30 +00:00
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:
parent
d8fd4c80bf
commit
790a03ec28
@ -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):
|
||||
|
||||
@ -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 ")
|
||||
|
||||
@ -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
|
||||
|
||||
138
nanobot/utils/webui_titles.py
Normal file
138
nanobot/utils/webui_titles.py
Normal 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,
|
||||
)
|
||||
@ -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()
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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 (
|
||||
<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">
|
||||
<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">
|
||||
<span className="relative flex h-2 w-2">
|
||||
<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 (
|
||||
<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">
|
||||
<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-sm text-muted-foreground">{state.message}</p>
|
||||
<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 {
|
||||
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 |
|
||||
>
|
||||
<div
|
||||
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",
|
||||
desktopSidebarOpen ? "translate-x-0" : "-translate-x-full",
|
||||
)}
|
||||
style={{ width: SIDEBAR_WIDTH }}
|
||||
>
|
||||
<Sidebar {...sidebarProps} onCollapse={closeDesktopSidebar} />
|
||||
</div>
|
||||
@ -312,7 +305,8 @@ function Shell({ onModelNameChange }: { onModelNameChange: (modelName: string |
|
||||
<SheetContent
|
||||
side="left"
|
||||
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} />
|
||||
</SheetContent>
|
||||
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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 (
|
||||
<div className="px-3 py-6 text-xs text-muted-foreground">
|
||||
{t("chat.noSessions")}
|
||||
<div className="px-3 py-6 text-[12px] leading-5 text-muted-foreground/80">
|
||||
{emptyLabel ?? t("chat.noSessions")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const groups = groupSessions(sessions, {
|
||||
today: t("chat.groups.today"),
|
||||
yesterday: t("chat.groups.yesterday"),
|
||||
earlier: t("chat.groups.earlier"),
|
||||
});
|
||||
|
||||
return (
|
||||
<ScrollArea className="h-full">
|
||||
<ul className="space-y-0.5 px-2 py-1">
|
||||
{sessions.map((s) => {
|
||||
const active = s.key === activeKey;
|
||||
const title = titleFor(
|
||||
s,
|
||||
t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }),
|
||||
);
|
||||
return (
|
||||
<li key={s.key}>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex items-center gap-2 rounded-md px-2 py-1.5 text-[12.5px] transition-colors",
|
||||
active
|
||||
? "bg-sidebar-accent/80 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--border)/0.4)]"
|
||||
: "text-sidebar-foreground/88 hover:bg-sidebar-accent/45",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(s.key)}
|
||||
className="flex min-w-0 flex-1 flex-col items-start text-left"
|
||||
>
|
||||
<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"
|
||||
<div className="space-y-3 px-2 py-1.5">
|
||||
{groups.map((group) => (
|
||||
<section key={group.label} aria-label={group.label}>
|
||||
<div className="px-2 pb-1 text-[12px] font-medium text-muted-foreground/65">
|
||||
{group.label}
|
||||
</div>
|
||||
<ul className="space-y-0.5">
|
||||
{group.sessions.map((s) => {
|
||||
const active = s.key === activeKey;
|
||||
const title = titleFor(
|
||||
s,
|
||||
t("chat.fallbackTitle", { id: s.chatId.slice(0, 6) }),
|
||||
);
|
||||
return (
|
||||
<li key={s.key}>
|
||||
<div
|
||||
className={cn(
|
||||
"group flex min-h-8 items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
|
||||
active
|
||||
? "bg-sidebar-accent/70 text-sidebar-accent-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.28)]"
|
||||
: "text-sidebar-foreground/82 hover:bg-sidebar-accent/50 hover:text-sidebar-foreground",
|
||||
)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
{t("chat.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(s.key)}
|
||||
className="min-w-0 flex-1 py-1.5 text-left"
|
||||
>
|
||||
<span className="block w-full truncate font-medium leading-5">{title}</span>
|
||||
</button>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenuTrigger
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@ -79,20 +79,8 @@ export function ChatPane({ session, onNewChat }: ChatPaneProps) {
|
||||
<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-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">
|
||||
What's on your mind?
|
||||
What can I do for you?
|
||||
</h1>
|
||||
<p className="max-w-md text-center text-sm text-muted-foreground">
|
||||
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..."
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -6,21 +6,21 @@ import { useClient } from "@/providers/ClientProvider";
|
||||
import type { ConnectionStatus } from "@/lib/types";
|
||||
|
||||
const COPY: Record<ConnectionStatus, { color: string }> = {
|
||||
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 (
|
||||
<span
|
||||
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,
|
||||
)}
|
||||
aria-live="polite"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState } from "react";
|
||||
import { ChevronRight, FileIcon, ImageIcon, PlaySquare, Wrench } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Check, ChevronRight, Copy, FileIcon, ImageIcon, PlaySquare, Wrench } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { ImageLightbox } from "@/components/ImageLightbox";
|
||||
@ -21,8 +21,33 @@ interface MessageBubbleProps {
|
||||
* collapsible group so intermediate steps never masquerade as replies.
|
||||
*/
|
||||
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";
|
||||
|
||||
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 <TraceGroup message={message} animClass={baseAnim} />;
|
||||
}
|
||||
@ -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 (
|
||||
<div className={cn("w-full text-sm", baseAnim)} style={{ lineHeight: "var(--cjk-line-height)" }}>
|
||||
{empty && message.isStreaming ? (
|
||||
@ -69,6 +95,27 @@ export function MessageBubble({ message }: MessageBubbleProps) {
|
||||
<MarkdownText>{message.content}</MarkdownText>
|
||||
{message.isStreaming && <StreamCursor />}
|
||||
{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>
|
||||
|
||||
@ -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 (
|
||||
<aside className="flex h-full w-full flex-col border-r border-sidebar-border/70 bg-sidebar text-sidebar-foreground">
|
||||
<div className="flex items-center justify-between px-3 pb-2 pt-3">
|
||||
<nav
|
||||
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">
|
||||
<source srcSet="/brand/nanobot_logo.webp" type="image/webp" />
|
||||
<img
|
||||
src="/brand/nanobot_logo.png"
|
||||
alt="nanobot"
|
||||
className="h-7 w-auto select-none object-contain"
|
||||
className="h-6 w-auto select-none object-contain opacity-95"
|
||||
draggable={false}
|
||||
/>
|
||||
</picture>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("sidebar.toggleTheme")}
|
||||
onClick={props.onToggleTheme}
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground hover:bg-sidebar-accent hover:text-sidebar-foreground"
|
||||
>
|
||||
{props.theme === "dark" ? (
|
||||
<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>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
aria-label={t("sidebar.collapse")}
|
||||
onClick={props.onCollapse}
|
||||
className="h-7 w-7 rounded-lg text-muted-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground"
|
||||
>
|
||||
<PanelLeftClose className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</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
|
||||
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"
|
||||
>
|
||||
<SquarePen className="h-3.5 w-3.5" />
|
||||
{t("sidebar.newChat")}
|
||||
</Button>
|
||||
</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">
|
||||
<ChatList
|
||||
sessions={props.sessions}
|
||||
sessions={filteredSessions}
|
||||
activeKey={props.activeKey}
|
||||
loading={props.loading}
|
||||
emptyLabel={
|
||||
normalizedQuery ? t("sidebar.noSearchResults") : t("chat.noSessions")
|
||||
}
|
||||
onSelect={props.onSelect}
|
||||
onRequestDelete={props.onRequestDelete}
|
||||
/>
|
||||
</div>
|
||||
<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 />
|
||||
<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>
|
||||
</aside>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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({
|
||||
<div
|
||||
className={cn(
|
||||
"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">
|
||||
@ -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",
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
{modelLabel ? (
|
||||
<span
|
||||
@ -321,7 +323,9 @@ export function ThreadComposer({
|
||||
className={cn(
|
||||
"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",
|
||||
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
|
||||
@ -331,19 +335,23 @@ export function ThreadComposer({
|
||||
<span className="truncate">{modelLabel}</span>
|
||||
</span>
|
||||
) : null}
|
||||
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
|
||||
{t("thread.composer.sendHint")}
|
||||
</span>
|
||||
{!isHero ? (
|
||||
<span className="hidden select-none text-[10.5px] text-muted-foreground/60 sm:inline">
|
||||
{t("thread.composer.sendHint")}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<span className="sm:hidden" aria-hidden />
|
||||
<span className={cn(isHero ? "hidden" : "sm:hidden")} aria-hidden />
|
||||
<Button
|
||||
type="submit"
|
||||
size="icon"
|
||||
disabled={!canSend}
|
||||
aria-label={t("thread.composer.send")}
|
||||
className={cn(
|
||||
"rounded-full border border-border/70 bg-secondary/85 text-secondary-foreground shadow-none transition-transform hover:bg-accent",
|
||||
isHero ? "h-8.5 w-8.5" : "h-7.5 w-7.5",
|
||||
isHero
|
||||
? "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",
|
||||
)}
|
||||
>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { PanelLeftOpen } from "lucide-react";
|
||||
import { Menu, Moon, PanelLeftOpen, Settings, Sun } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
@ -7,17 +7,66 @@ import { cn } from "@/lib/utils";
|
||||
interface ThreadHeaderProps {
|
||||
title: string;
|
||||
onToggleSidebar: () => void;
|
||||
onGoHome: () => void;
|
||||
theme: "light" | "dark";
|
||||
onToggleTheme: () => void;
|
||||
onOpenSettings: () => void;
|
||||
hideSidebarToggleOnDesktop?: boolean;
|
||||
minimal?: boolean;
|
||||
}
|
||||
|
||||
export function ThreadHeader({
|
||||
title,
|
||||
onToggleSidebar,
|
||||
onGoHome,
|
||||
theme,
|
||||
onToggleTheme,
|
||||
onOpenSettings,
|
||||
hideSidebarToggleOnDesktop = false,
|
||||
minimal = false,
|
||||
}: ThreadHeaderProps) {
|
||||
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 (
|
||||
<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">
|
||||
@ -33,19 +82,34 @@ export function ThreadHeader({
|
||||
>
|
||||
<PanelLeftOpen className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<button
|
||||
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
|
||||
/>
|
||||
<div className="flex min-w-0 items-center rounded-md px-1.5 py-1 text-[12px] font-medium text-muted-foreground">
|
||||
<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 aria-hidden className="pointer-events-none absolute inset-x-0 top-full h-4" />
|
||||
|
||||
@ -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<string | null>;
|
||||
onGoHome?: () => void;
|
||||
onNewChat?: () => void;
|
||||
onCreateChat?: () => Promise<string | null>;
|
||||
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 = (
|
||||
<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 ? (
|
||||
@ -139,20 +246,10 @@ export function ThreadShell({
|
||||
{t("thread.loadingConversation")}
|
||||
</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="inline-flex items-center gap-2 text-[11px] font-medium text-muted-foreground">
|
||||
<img
|
||||
src="/brand/nanobot_icon.png"
|
||||
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 className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
|
||||
<h1 className="text-balance text-[40px] font-normal leading-tight tracking-[-0.045em] text-foreground sm:text-[48px]">
|
||||
{t("thread.empty.greeting")}
|
||||
</h1>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -161,57 +258,17 @@ export function ThreadShell({
|
||||
<ThreadHeader
|
||||
title={title}
|
||||
onToggleSidebar={onToggleSidebar}
|
||||
onGoHome={onGoHome}
|
||||
theme={theme}
|
||||
onToggleTheme={onToggleTheme}
|
||||
onOpenSettings={onOpenSettings}
|
||||
hideSidebarToggleOnDesktop={hideSidebarToggleOnDesktop}
|
||||
minimal={!session && !loading}
|
||||
/>
|
||||
<ThreadViewport
|
||||
messages={messages}
|
||||
isStreaming={isStreaming}
|
||||
emptyState={emptyState}
|
||||
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"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
composer={composer}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@ -82,9 +82,9 @@ export function ThreadViewport({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto flex min-h-full w-full max-w-[64rem] 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 max-w-[40rem] flex-col gap-5">
|
||||
<div className="mx-auto flex min-h-full w-full max-w-[72rem] flex-col px-4">
|
||||
<div className="flex w-full flex-1 items-center justify-center pb-[7vh] pt-8">
|
||||
<div className="flex w-full max-w-[58rem] flex-col gap-6">
|
||||
{emptyState}
|
||||
<div className="w-full">{composer}</div>
|
||||
</div>
|
||||
|
||||
@ -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%;
|
||||
}
|
||||
|
||||
@ -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[]) => {
|
||||
|
||||
@ -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"),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": "图片预览",
|
||||
|
||||
@ -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 ?? "",
|
||||
}));
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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: "",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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(<App />);
|
||||
|
||||
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(<App />);
|
||||
|
||||
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(<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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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(<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", () => {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -9,15 +9,38 @@ describe("ThreadComposer", () => {
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
modelLabel="claude-opus-4-5"
|
||||
placeholder="What's on your mind?"
|
||||
placeholder="Ask anything..."
|
||||
variant="hero"
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<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");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 () => {
|
||||
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,
|
||||
<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 () => {
|
||||
@ -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();
|
||||
});
|
||||
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user