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,
|
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):
|
||||||
|
|||||||
@ -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 ")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
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."""
|
"""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()
|
||||||
|
|||||||
@ -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")
|
||||||
|
|||||||
@ -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():
|
||||||
|
|||||||
@ -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()
|
||||||
|
|||||||
@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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%;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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[]) => {
|
||||||
|
|||||||
@ -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"),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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": "图片预览",
|
||||||
|
|||||||
@ -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 ?? "",
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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: "",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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", () => {
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user