Compare commits

...
Author SHA1 Message Date
Xubin Ren 15d7e7c822 feat(webui): add quick and temporary chats 2026-08-01 23:10:46 +08:00
Xubin Ren db6c9effc3 fix(webui): position sidebar highlight on mount 2026-08-01 23:01:43 +08:00
Xubin Ren 0cb7dd5cc9 refactor(webui): reuse sidebar selection highlight 2026-08-01 23:01:43 +08:00
Xubin Ren e1894d6f0b fix(providers): respect explicit cloud namespaces 2026-08-01 20:25:58 +08:00
5eb818e800 fix(providers): require api_base before local provider wins on keyword match
Ollama's spec keeps "nemotron" as a keyword so bare `nemotron-3-nano`
auto-routes to a configured Ollama install (PR #1863). NVIDIA NIM was
later registered with the same "nemotron" keyword (commit 046d0831),
creating the only keyword collision in the registry.

In `_match_provider`, the keyword loop accepted any local provider on
`spec.is_local` alone — no api_base check. Models like
`nvidia/nemotron-3-super-120b-a12b` (intended for OpenRouter or NVIDIA
NIM) were therefore hijacked to http://localhost:11434/v1 even when the
user had never configured Ollama, causing silent connection errors at
runtime.

Add the same api_base gate the local-fallback loop already uses: a local
provider only wins by keyword when the user has actually set its
api_base. Preserves PR #1863's intent for users who configured Ollama;
fixes the silent hijack for everyone else.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-08-01 20:25:58 +08:00
santhrealandXubin Ren 4c387f6633 fix(memory): handle non-string timestamp and missing role in raw_archive 2026-08-01 20:14:28 +08:00
Xubin Ren e152e7bc0b test(cron): cover stop during manual execution 2026-08-01 20:03:19 +08:00
yu-xin-candXubin Ren e26e09c205 fix(cron): preserve manual run completion state 2026-08-01 20:03:19 +08:00
KDBandXubin Ren f3bbb543d0 refactor(cli): narrow Pyright suppressions 2026-08-01 19:52:08 +08:00
KDBandXubin Ren b1030ab131 fix(exec): preserve wait targets across response truncation 2026-08-01 19:40:36 +08:00
KDBandXubin Ren 39bb20c76b fix(session): tolerate malformed persisted session summary
AutoCompact.prepare_session runs on the turn hot path
(AgentLoop._compact_session) and read the persisted _last_summary metadata
with an unguarded meta['text'] and datetime.fromisoformat(meta['last_active']).
A _last_summary dict that was hand-edited or written by another version
(missing text/last_active, or a non-ISO last_active) raised KeyError/ValueError
out of the turn.

Sibling readers already tolerate the same data: estimate_session_prompt_tokens
uses .get('text') and _archive parses inside try/except. Mirror that tolerance:
skip when text is unusable, and fall back to the session's own updated_at (the
value the writer persists) when last_active is missing or unparseable, so the
archived summary is preserved instead of crashing the turn.
2026-08-01 19:29:16 +08:00
60 changed files with 2340 additions and 359 deletions
+15 -4
View File
@@ -134,10 +134,21 @@ class AutoCompact:
if entry:
return session, self._format_summary(entry[0], entry[1])
# Cold path: summary persisted in session metadata (process restarted).
# Persisted metadata may outlive schema changes; a malformed summary must
# not abort turn preparation.
meta = session.metadata.get("_last_summary")
if isinstance(meta, dict):
return session, self._format_summary(
cast(str, meta["text"]),
datetime.fromisoformat(cast(str, meta["last_active"])),
)
summary_meta = cast(dict[str, object], meta)
text = summary_meta.get("text")
if isinstance(text, str) and text:
raw_last_active = summary_meta.get("last_active")
try:
last_active = (
datetime.fromisoformat(raw_last_active)
if isinstance(raw_last_active, str)
else session.updated_at
)
except ValueError:
last_active = session.updated_at
return session, self._format_summary(text, last_active)
return session, None
+12 -12
View File
@@ -217,16 +217,18 @@ class ContextBuilder:
include_memory_recent_history: bool = True,
session_key: str | None = None,
unified_session: bool = False,
conversation_only: bool = False,
) -> list[dict[str, Any]]:
"""Build the complete message list for an LLM call."""
root = workspace or self.workspace
active_skill_names = (
self.skills.get_explicitly_invoked_skills(current_message)
if current_role == "user"
else []
)
messages: list[dict[str, Any]] = [
{
messages = list(history)
if not conversation_only:
root = workspace or self.workspace
active_skill_names = (
self.skills.get_explicitly_invoked_skills(current_message)
if current_role == "user"
else []
)
messages.insert(0, {
"role": "system",
"content": self.build_system_prompt(
active_skill_names=active_skill_names,
@@ -237,16 +239,14 @@ class ContextBuilder:
session_key=session_key,
unified_session=unified_session,
),
},
*history,
]
})
current = self.build_current_message(
current_message,
media=media,
current_role=current_role,
runtime_context_blocks=runtime_context_blocks,
)
if messages[-1].get("role") == current_role:
if messages and messages[-1].get("role") == current_role:
last = dict(messages[-1])
last["content"] = self._merge_message_content(
last.get("content"),
+31 -7
View File
@@ -723,6 +723,7 @@ class AgentLoop:
include_memory_recent_history=not ctx.ephemeral,
session_key=ctx.session.key,
unified_session=self._unified_session,
conversation_only=ctx.session.transient is True,
)
def _request_context_for_turn(self, ctx: TurnContext) -> RequestContext:
@@ -750,10 +751,12 @@ class AgentLoop:
self,
ctx: TurnContext,
) -> list[RuntimeContextBlock]:
if ctx.require_session().transient is True:
return []
assert ctx.request_context is not None
return await self._resolve_runtime_context_for_request(
ctx.request_context,
ctx.tools or self.tools,
ctx.tools if ctx.tools is not None else self.tools,
)
async def _resolve_runtime_context_for_request(
@@ -784,18 +787,24 @@ class AgentLoop:
else:
logger.warning("Command '{}' matched but dispatch returned None", raw)
async def _cancel_active_tasks(self, key: str) -> int:
"""Cancel and await all active tasks and subagents for *key*.
async def cancel_active_turn(self, key: str) -> int:
"""Cancel active work and discard queued follow-ups for *key*.
Returns the total number of cancelled tasks + subagents.
"""
pending = self._pending_queues.pop(key, None)
queued = 0
if pending is not None:
while not pending.empty():
pending.get_nowait()
queued += 1
tasks = tuple(self._active_tasks.pop(key, set()))
cancelled = sum(1 for t in tasks if not t.done() and t.cancel())
for t in tasks:
with suppress(asyncio.CancelledError, Exception):
await t
sub_cancelled = await self.subagents.cancel_by_session(key)
return cancelled + sub_cancelled
return queued + cancelled + sub_cancelled
def _effective_session_key(self, msg: InboundMessage) -> str:
"""Return the session key used for task routing and mid-turn injections."""
@@ -922,7 +931,10 @@ class AgentLoop:
if isinstance(metadata_value, dict)
else {}
)
if pending_msg.channel != "system":
if (
pending_msg.channel != "system"
and not (session is not None and session.transient is True)
):
scope = self.workspace_scopes.for_turn(
channel=pending_msg.channel,
message_metadata=metadata,
@@ -1002,7 +1014,7 @@ class AgentLoop:
message_metadata=metadata,
session_metadata=session.metadata if session is not None else None,
)
effective_tools = tools or self.tools
effective_tools = tools if tools is not None else self.tools
request_ctx = request_context or RequestContext(
channel=channel,
chat_id=chat_id,
@@ -1160,6 +1172,11 @@ class AgentLoop:
effective_key = self._effective_session_key(msg)
if await agent_context.handle_runtime_control(self, msg, self.tools):
continue
if (
msg.transient_session
and not self.sessions.is_transient_active(effective_key)
):
continue
if self.commands.is_priority(raw):
await self._dispatch_command_inline(
msg, effective_key, raw,
@@ -1271,6 +1288,8 @@ class AgentLoop:
session_key,
exc_info=True,
)
if msg.transient_session:
raise
# Preserve partial context from the interrupted turn so
# the user does not lose tool results and assistant
# messages accumulated before /stop. The checkpoint was
@@ -1573,13 +1592,16 @@ class AgentLoop:
if ctx.session is None:
ctx.session = self.sessions.get_or_create(ctx.session_key)
session = ctx.session
if session.transient is True:
ctx.ephemeral = True
ctx.tools = ToolRegistry()
self._remember_unified_session_route(
session,
msg,
is_user_turn=ctx.original_user_text is not None,
)
await ctx.delivery.started()
if ctx.kind is TurnKind.USER:
if ctx.kind is TurnKind.USER and not session.transient:
self.workspace_scopes.persist_message_scope(session, msg)
if self._restore_runtime_checkpoint(session):
@@ -1589,6 +1611,8 @@ class AgentLoop:
async def _compact_session(self, ctx: TurnContext) -> None:
session = ctx.require_session()
if session.transient is True:
return
ctx.session, pending = self.auto_compact.prepare_session(
session,
ctx.session_key,
+4 -5
View File
@@ -713,11 +713,10 @@ class MemoryStore:
if tools_used
else ""
)
timestamp = cast(str, message.get("timestamp", "?"))
role = cast(str, message["role"])
lines.append(
f"[{timestamp[:16]}] {role.upper()}{tools}: {content}"
)
raw_timestamp = message.get("timestamp")
timestamp = str(raw_timestamp) if raw_timestamp is not None else "?"
role = str(message.get("role") or "unknown")
lines.append(f"[{timestamp[:16]}] {role.upper()}{tools}: {content}")
return "\n".join(lines)
def raw_archive(
+1 -1
View File
@@ -660,7 +660,7 @@ class WriteStdinTool(Tool):
close_stdin=close_stdin if first else False,
terminate=terminate if first else False,
yield_time_ms=step_ms,
max_output_chars=max_output_chars,
max_output_chars=MAX_OUTPUT_CHARS,
owner_session_key=current_request_session_key(),
)
first = False
+2
View File
@@ -18,6 +18,7 @@ INBOUND_META_RUNTIME_CONTROL = "_runtime_control"
RUNTIME_CONTROL_ACK = "_ack"
RUNTIME_CONTROL_MCP_RELOAD = "mcp_reload"
RUNTIME_CONTROL_IMAGE_GENERATION_RELOAD = "image_generation_reload"
INBOUND_META_TRANSIENT_SESSION = "_transient_session"
@dataclass
@@ -32,6 +33,7 @@ class InboundMessage:
media: list[str] = field(default_factory=list) # Media URLs
metadata: dict[str, Any] = field(default_factory=dict) # Channel-specific data
session_key_override: str | None = None # Optional override for thread-scoped sessions
transient_session: bool = False # In-memory session whose lifetime is owned by the channel
@property
def session_key(self) -> str:
+8 -2
View File
@@ -8,7 +8,11 @@ from typing import Any, cast
from loguru import logger
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.events import (
INBOUND_META_TRANSIENT_SESSION,
InboundMessage,
OutboundMessage,
)
from nanobot.bus.queue import MessageBus
from nanobot.pairing import (
PAIRING_CODE_META_KEY,
@@ -277,7 +281,8 @@ class BaseChannel(ABC):
)
return
meta = metadata or {}
meta = dict(metadata or {})
transient_session = meta.pop(INBOUND_META_TRANSIENT_SESSION, False) is True
if self.supports_streaming:
meta = {**meta, "_wants_stream": True}
@@ -289,6 +294,7 @@ class BaseChannel(ABC):
media=media or [],
metadata=meta,
session_key_override=session_key,
transient_session=transient_session,
)
await self.bus.publish_inbound(msg)
+4 -1
View File
@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
import hashlib
import inspect
from collections.abc import Callable, Iterable
from collections.abc import Awaitable, Callable, Iterable
from contextlib import suppress
from pathlib import Path
from typing import TYPE_CHECKING, Any, cast
@@ -97,6 +97,7 @@ class ChannelManager:
webui_runtime_model_name: Callable[[], str | None] | None = None,
webui_cron_pending_job_ids: Callable[[str], set[str]] | None = None,
webui_local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
webui_cancel_active_turn: Callable[[str], Awaitable[int]] | None = None,
webui_static_dist: bool = True,
webui_runtime_surface: str = "browser",
webui_runtime_capabilities: dict[str, Any] | None = None,
@@ -110,6 +111,7 @@ class ChannelManager:
self._webui_runtime_model_name = webui_runtime_model_name
self._webui_cron_pending_job_ids = webui_cron_pending_job_ids
self._webui_local_trigger_pending_ids = webui_local_trigger_pending_ids
self._webui_cancel_active_turn = webui_cancel_active_turn
self._webui_static_dist = webui_static_dist
self._webui_runtime_surface = webui_runtime_surface
self._webui_runtime_capabilities = dict(webui_runtime_capabilities or {})
@@ -178,6 +180,7 @@ class ChannelManager:
local_trigger_store=self._local_trigger_store,
cron_pending_job_ids=self._webui_cron_pending_job_ids,
local_trigger_pending_ids=self._webui_local_trigger_pending_ids,
cancel_active_turn=self._webui_cancel_active_turn,
channel_feature_action=self.apply_channel_feature_action,
channel_runtime_status=self.get_status,
skill_state_action=self._webui_skill_state_action,
+204 -15
View File
@@ -18,7 +18,11 @@ from websockets.asyncio.server import ServerConnection, serve, unix_serve
from websockets.exceptions import ConnectionClosed
from websockets.http11 import Request as WsRequest
from nanobot.bus.events import OUTBOUND_META_AGENT_UI, OutboundMessage
from nanobot.bus.events import (
INBOUND_META_TRANSIENT_SESSION,
OUTBOUND_META_AGENT_UI,
OutboundMessage,
)
from nanobot.bus.outbound_events import (
GoalStateSyncEvent,
GoalStatusEvent,
@@ -32,6 +36,10 @@ from nanobot.bus.outbound_events import (
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
from nanobot.channels.websocket.temporary_chat import (
TemporaryChatLifecycle,
TemporaryChatLifecycleError,
)
from nanobot.command.builtin import builtin_command_starts_agent_turn
from nanobot.config.schema import Base
from nanobot.runtime_context import (
@@ -76,6 +84,8 @@ from nanobot.webui.websocket_logging import websockets_server_logger
# Plain HTTP WebUI routes also run through websockets.process_request.
_WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0
_TEMPORARY_CHAT_ID_PREFIX = "temporary-"
_TEMPORARY_COMMANDS = frozenset({"/model", "/stop"})
class WebSocketConfig(Base):
@@ -215,6 +225,10 @@ def _is_valid_chat_id(value: Any) -> TypeGuard[str]:
return isinstance(value, str) and _CHAT_ID_RE.match(value) is not None
def _is_temporary_chat_id(value: Any) -> TypeGuard[str]:
return _is_valid_chat_id(value) and value.startswith(_TEMPORARY_CHAT_ID_PREFIX)
def _parse_envelope(raw: str) -> dict[str, Any] | None:
"""Return a typed envelope dict if the frame is a new-style JSON envelope, else None.
@@ -286,6 +300,13 @@ class WebSocketChannel(BaseChannel):
self._workspaces = gateway.workspaces
self._stream_text_buffers: dict[tuple[str, str], list[str]] = {}
self._temporary_chats = TemporaryChatLifecycle(
sessions=gateway.session_manager,
cancel_active_turn=gateway.cancel_active_turn,
attach=self._attach,
detach=self._detach,
clear_stream_buffers=self._clear_stream_buffers,
)
# -- Subscription bookkeeping -------------------------------------------
@@ -297,6 +318,23 @@ class WebSocketChannel(BaseChannel):
self._subs.setdefault(chat_id, set()).add(connection)
self._conn_chats.setdefault(connection, set()).add(chat_id)
def _detach(self, connection: ServerConnection, chat_id: str) -> None:
chats = self._conn_chats.get(connection)
if chats is not None:
chats.discard(chat_id)
if not chats:
self._conn_chats.pop(connection, None)
subscribers = self._subs.get(chat_id)
if subscribers is not None:
subscribers.discard(connection)
if not subscribers:
self._subs.pop(chat_id, None)
def _clear_stream_buffers(self, chat_id: str) -> None:
for key in tuple(self._stream_text_buffers):
if key[0] == chat_id:
self._stream_text_buffers.pop(key, None)
async def send_webui_protocol_error(
self,
connection: ServerConnection,
@@ -325,18 +363,15 @@ class WebSocketChannel(BaseChannel):
)
await self._hydrate_after_subscribe(fork_id)
def _cleanup_connection(self, connection: ServerConnection) -> None:
async def _cleanup_connection(self, connection: ServerConnection) -> None:
"""Remove *connection* from every subscription set; safe to call multiple times."""
chat_ids = self._conn_chats.pop(connection, set())
for cid in chat_ids:
subs = self._subs.get(cid)
if subs is None:
continue
subs.discard(connection)
if not subs:
self._subs.pop(cid, None)
self._conn_default.pop(connection, None)
self._webui_connections.discard(connection)
try:
await self._temporary_chats.discard_owner(connection)
finally:
for chat_id in tuple(self._conn_chats.get(connection, ())):
self._detach(connection, chat_id)
self._conn_default.pop(connection, None)
self._webui_connections.discard(connection)
async def _maybe_push_active_goal_state(self, chat_id: str) -> None:
"""Replay an active sustained goal from session metadata after *chat_id* is subscribed.
@@ -387,7 +422,7 @@ class WebSocketChannel(BaseChannel):
try:
await connection.send(raw)
except ConnectionClosed:
self._cleanup_connection(connection)
await self._cleanup_connection(connection)
except Exception as e:
self.logger.warning("failed to send {} event: {}", event, e)
@@ -609,7 +644,7 @@ class WebSocketChannel(BaseChannel):
except Exception as e:
self.logger.debug("connection ended: {}", e)
finally:
self._cleanup_connection(connection)
await self._cleanup_connection(connection)
# -- Inbound WebSocket envelopes ---------------------------------------
@@ -647,11 +682,36 @@ class WebSocketChannel(BaseChannel):
if t == "fork_chat":
await handle_webui_fork_chat(self, connection, envelope)
return
if t == "discard_temporary_chat":
cid = envelope.get("chat_id")
if not _is_temporary_chat_id(cid):
await self._send_event(connection, "error", detail="invalid temporary chat_id")
return
try:
await self._temporary_chats.discard(connection, cid)
except TemporaryChatLifecycleError as exc:
await self._send_event(
connection,
"error",
detail=exc.detail,
chat_id=cid,
)
return
await self._send_event(connection, "temporary_chat_discarded", chat_id=cid)
return
if t == "attach":
cid = envelope.get("chat_id")
if not _is_valid_chat_id(cid):
await self._send_event(connection, "error", detail="invalid chat_id")
return
if _is_temporary_chat_id(cid):
await self._send_event(
connection,
"error",
detail="temporary_chat_cannot_attach",
chat_id=cid,
)
return
self._attach(connection, cid)
await self._send_event(connection, "attached", chat_id=cid)
await self._hydrate_after_subscribe(cid)
@@ -661,6 +721,14 @@ class WebSocketChannel(BaseChannel):
if not _is_valid_chat_id(cid):
await self._send_event(connection, "error", detail="invalid chat_id")
return
if _is_temporary_chat_id(cid):
await self._send_event(
connection,
"error",
detail="temporary_chat_has_no_workspace",
chat_id=cid,
)
return
scope = await self._workspace_scope_or_error(
connection,
lambda: self._workspaces.scope_for_set_request(
@@ -692,6 +760,15 @@ class WebSocketChannel(BaseChannel):
if not _is_valid_chat_id(cid):
await self._send_event(connection, "error", detail="invalid chat_id")
return
temporary = envelope.get("temporary") is True
if _is_temporary_chat_id(cid) != temporary:
await self._send_event(
connection,
"error",
detail="temporary_chat_mismatch",
chat_id=cid,
)
return
raw_turn_id = envelope.get("turn_id")
turn_id = raw_turn_id if isinstance(raw_turn_id, str) and raw_turn_id else None
rejection_fields = {
@@ -728,6 +805,17 @@ class WebSocketChannel(BaseChannel):
**rejection_fields,
)
return
if temporary:
await self._dispatch_temporary_message(
connection,
client_id=client_id,
chat_id=cid,
content=content,
turn_id=turn_id,
envelope=envelope,
rejection_fields=rejection_fields,
)
return
raw_media = envelope.get("media")
media_paths: list[str] = []
@@ -849,6 +937,103 @@ class WebSocketChannel(BaseChannel):
return
await self._send_event(connection, "error", detail=f"unknown type: {t!r}")
async def _dispatch_temporary_message(
self,
connection: ServerConnection,
*,
client_id: str,
chat_id: str,
content: str,
turn_id: str | None,
envelope: dict[str, Any],
rejection_fields: dict[str, str],
) -> None:
"""Admit a WebUI-only message without durable or local-agent capabilities."""
if connection not in self._webui_connections:
await self._send_event(
connection,
"error",
detail="temporary_chat_unavailable",
**rejection_fields,
)
return
forbidden = (
"media",
"cli_apps",
"mcp_presets",
"quoted_context",
"workspace_scope",
)
if any(field in envelope for field in forbidden):
await self._send_event(
connection,
"error",
detail="temporary_chat_capability_rejected",
**rejection_fields,
)
return
if not content.strip():
await self._send_event(
connection,
"error",
detail="missing content",
**rejection_fields,
)
return
command = content.strip().partition(" ")[0].lower()
if command.startswith("/") and command not in _TEMPORARY_COMMANDS:
await self._send_event(
connection,
"error",
detail="temporary_chat_command_rejected",
**rejection_fields,
)
return
try:
session_key = self._temporary_chats.claim(connection, chat_id)
except TemporaryChatLifecycleError as exc:
await self._send_event(
connection,
"error",
detail=exc.detail,
**rejection_fields,
)
return
metadata: dict[str, Any] = {
"remote": getattr(connection, "remote_address", None),
"webui": True,
INBOUND_META_TRANSIENT_SESSION: True,
**self._transcripts.client_turn_metadata(turn_id),
}
queued_owner = None
if builtin_command_starts_agent_turn(content):
queued_owner = register_queued_websocket_turn_if_idle(chat_id, turn_id)
if queued_owner is not None:
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner
accepted = False
try:
await self._handle_message(
sender_id=client_id,
chat_id=chat_id,
content=content,
metadata=metadata,
session_key=session_key,
is_dm=False,
)
accepted = True
finally:
if not accepted and queued_owner is not None:
clear_websocket_turn_if_current(chat_id, queued_owner)
if turn_id:
await self._send_event(
connection,
"message_accepted",
chat_id=chat_id,
turn_id=turn_id,
)
async def _workspace_scope_or_error(
self,
connection: ServerConnection,
@@ -889,6 +1074,8 @@ class WebSocketChannel(BaseChannel):
except Exception as e:
self.logger.warning("server task error during shutdown: {}", e)
self._server_task = None
for connection in tuple(self._conn_chats):
await self._temporary_chats.discard_owner(connection)
self._subs.clear()
self._conn_chats.clear()
self._conn_default.clear()
@@ -906,7 +1093,7 @@ class WebSocketChannel(BaseChannel):
try:
await connection.send(raw)
except ConnectionClosed:
self._cleanup_connection(connection)
await self._cleanup_connection(connection)
self.logger.warning("connection gone{}", label)
except Exception:
self.logger.exception("send failed{}", label)
@@ -923,6 +1110,8 @@ class WebSocketChannel(BaseChannel):
transcript_overrides: dict[str, Any] | None = None,
) -> bool:
"""Persist one canonical turn event and retain unsafe owners on failure."""
if _is_temporary_chat_id(chat_id):
return True
persisted = self._transcripts.prepare_and_append(
chat_id,
event,
@@ -0,0 +1,85 @@
"""Connection-owned lifecycle for WebUI Temporary Chat sessions."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from websockets.asyncio.server import ServerConnection
from nanobot.session.manager import SessionManager
from nanobot.session.webui_turns import clear_websocket_turns
class TemporaryChatLifecycleError(RuntimeError):
"""A stable WebSocket protocol error raised by the temporary-chat lifecycle."""
def __init__(self, detail: str) -> None:
self.detail = detail
super().__init__(detail)
class TemporaryChatLifecycle:
"""Own temporary session identity, cancellation, and cleanup ordering."""
def __init__(
self,
*,
sessions: SessionManager | None,
cancel_active_turn: Callable[[str], Awaitable[int]] | None,
attach: Callable[[ServerConnection, str], None],
detach: Callable[[ServerConnection, str], None],
clear_stream_buffers: Callable[[str], None],
) -> None:
self._sessions = sessions
self._cancel_active_turn = cancel_active_turn
self._attach = attach
self._detach = detach
self._clear_stream_buffers = clear_stream_buffers
self._owners: dict[str, ServerConnection] = {}
def claim(self, owner: ServerConnection, chat_id: str) -> str:
"""Claim *chat_id* for *owner* and return its in-memory session key."""
if self._sessions is None or self._cancel_active_turn is None:
raise TemporaryChatLifecycleError("temporary_chat_unavailable")
current = self._owners.get(chat_id)
if current is not None and current is not owner:
raise TemporaryChatLifecycleError("temporary_chat_not_owned")
session_key = f"websocket:{chat_id}"
self._sessions.get_or_create_transient(session_key)
self._owners[chat_id] = owner
self._attach(owner, chat_id)
return session_key
async def discard(self, owner: ServerConnection, chat_id: str) -> None:
"""Discard an owned chat; an unused chat is already discarded."""
current = self._owners.get(chat_id)
if current is None:
return
if current is not owner:
raise TemporaryChatLifecycleError("temporary_chat_not_owned")
await self._discard_owned(owner, chat_id)
async def discard_owner(self, owner: ServerConnection) -> None:
"""Discard every temporary chat held by a disconnected owner."""
chat_ids = (
chat_id
for chat_id, current in self._owners.items()
if current is owner
)
for chat_id in tuple(chat_ids):
await self._discard_owned(owner, chat_id)
async def _discard_owned(self, owner: ServerConnection, chat_id: str) -> None:
self._owners.pop(chat_id, None)
self._detach(owner, chat_id)
session_key = f"websocket:{chat_id}"
assert self._sessions is not None
assert self._cancel_active_turn is not None
self._sessions.discard_transient(session_key)
try:
await self._cancel_active_turn(session_key)
finally:
clear_websocket_turns(chat_id)
self._clear_stream_buffers(chat_id)
@@ -111,6 +111,7 @@ def _basic_handler(bus: Any, **kw: Any) -> GatewayServices:
runtime_model_name=None,
runtime_surface=kw.get("runtime_surface", "browser"),
runtime_capabilities_overrides=kw.get("runtime_capabilities_overrides"),
cancel_active_turn=kw.get("cancel_active_turn"),
)
@@ -190,6 +191,182 @@ def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
wth._WEBSOCKET_TURN_OWNERS.clear()
@pytest.mark.asyncio
async def test_temporary_message_registers_in_memory_session(bus, tmp_path) -> None:
sessions = SessionManager(tmp_path)
cancel = AsyncMock(return_value=0)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(
bus,
session_manager=sessions,
cancel_active_turn=cancel,
),
)
connection = AsyncMock()
connection.remote_address = None
channel._webui_connections.add(connection)
chat_id = "temporary-test"
await channel._dispatch_envelope(
connection,
"client",
{
"type": "message",
"chat_id": chat_id,
"content": "hello",
"turn_id": "turn-1",
"temporary": True,
"webui": True,
},
)
inbound = bus.publish_inbound.await_args.args[0]
assert inbound.session_key == f"websocket:{chat_id}"
assert inbound.transient_session is True
assert sessions.is_transient_active(inbound.session_key) is True
assert sessions.get_cached(inbound.session_key).transient is True
assert read_transcript_lines(inbound.session_key) == []
assert json.loads(connection.send.await_args.args[0])["event"] == "message_accepted"
@pytest.mark.asyncio
@pytest.mark.parametrize(
"envelope",
[
{"type": "attach", "chat_id": "temporary-test"},
{
"type": "set_workspace_scope",
"chat_id": "temporary-test",
"workspace_scope": {},
},
{
"type": "message",
"chat_id": "temporary-test",
"content": "hello",
"temporary": True,
"media": [],
},
{
"type": "message",
"chat_id": "temporary-test",
"content": "/history",
"temporary": True,
},
],
)
async def test_temporary_chat_rejects_persistent_capabilities(
bus,
tmp_path,
envelope,
) -> None:
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(
bus,
session_manager=SessionManager(tmp_path),
cancel_active_turn=AsyncMock(return_value=0),
),
)
connection = AsyncMock()
connection.remote_address = None
channel._webui_connections.add(connection)
await channel._dispatch_envelope(connection, "client", envelope)
payload = json.loads(connection.send.await_args.args[0])
assert payload["event"] == "error"
bus.publish_inbound.assert_not_awaited()
@pytest.mark.asyncio
async def test_discard_temporary_chat_cancels_then_forgets_session(bus, tmp_path) -> None:
sessions = SessionManager(tmp_path)
cancel = AsyncMock(return_value=1)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(
bus,
session_manager=sessions,
cancel_active_turn=cancel,
),
)
connection = AsyncMock()
connection.remote_address = None
channel._webui_connections.add(connection)
chat_id = "temporary-test"
session_key = channel._temporary_chats.claim(connection, chat_id)
sessions.get_cached(session_key).add_message("user", "private")
await channel._dispatch_envelope(
connection,
"client",
{"type": "discard_temporary_chat", "chat_id": chat_id},
)
cancel.assert_awaited_once_with(session_key)
assert sessions.get_cached(session_key) is None
assert chat_id not in channel._subs
assert json.loads(connection.send.await_args.args[0]) == {
"event": "temporary_chat_discarded",
"chat_id": chat_id,
}
@pytest.mark.asyncio
async def test_discard_unused_temporary_chat_is_idempotent(bus, tmp_path) -> None:
cancel = AsyncMock(return_value=0)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(
bus,
session_manager=SessionManager(tmp_path),
cancel_active_turn=cancel,
),
)
connection = AsyncMock()
await channel._dispatch_envelope(
connection,
"client",
{"type": "discard_temporary_chat", "chat_id": "temporary-unused"},
)
cancel.assert_not_awaited()
assert json.loads(connection.send.await_args.args[0]) == {
"event": "temporary_chat_discarded",
"chat_id": "temporary-unused",
}
@pytest.mark.asyncio
async def test_disconnect_discards_owned_temporary_chat(bus, tmp_path) -> None:
sessions = SessionManager(tmp_path)
cancel = AsyncMock(return_value=1)
channel = WebSocketChannel(
{"enabled": True, "allowFrom": ["*"]},
bus,
gateway=_basic_handler(
bus,
session_manager=sessions,
cancel_active_turn=cancel,
),
)
connection = AsyncMock()
chat_id = "temporary-disconnect"
session_key = channel._temporary_chats.claim(connection, chat_id)
await channel._cleanup_connection(connection)
cancel.assert_awaited_once_with(session_key)
assert sessions.get_cached(session_key) is None
assert chat_id not in channel._subs
@pytest.mark.asyncio
async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None:
class Conn:
+8 -9
View File
@@ -1,7 +1,5 @@
"""Typer commands for foreground and background gateway control."""
# pyright: reportUnusedFunction=false
from __future__ import annotations
import subprocess
@@ -135,8 +133,9 @@ def create_gateway_app(
console.print()
console.print(result.content)
# Typer consumes these callbacks through decorator registration.
@gateway_app.callback(invoke_without_command=True)
def gateway(
def gateway( # pyright: ignore[reportUnusedFunction]
ctx: typer.Context,
port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
@@ -191,7 +190,7 @@ def create_gateway_app(
)
@gateway_app.command("status")
def gateway_status(
def gateway_status( # pyright: ignore[reportUnusedFunction]
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
) -> None:
@@ -199,7 +198,7 @@ def create_gateway_app(
print_status(runtime_for_instance(workspace=workspace, config=config).status())
@gateway_app.command("logs")
def gateway_logs(
def gateway_logs( # pyright: ignore[reportUnusedFunction]
tail: int = typer.Option(200, "--tail", help="Number of recent lines to show"),
follow: bool = typer.Option(True, "--follow/--no-follow", help="Follow new log output"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
@@ -217,7 +216,7 @@ def create_gateway_app(
console.print(line)
@gateway_app.command("stop")
def gateway_stop(
def gateway_stop( # pyright: ignore[reportUnusedFunction]
timeout: int = typer.Option(20, "--timeout", help="Stop timeout in seconds"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
config: str | None = typer.Option(None, "--config", "-c", help="Path to config file"),
@@ -233,7 +232,7 @@ def create_gateway_app(
raise typer.Exit(1)
@gateway_app.command("restart")
def gateway_restart(
def gateway_restart( # pyright: ignore[reportUnusedFunction]
port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
@@ -266,7 +265,7 @@ def create_gateway_app(
raise typer.Exit(1)
@gateway_app.command("install-service")
def gateway_install_service(
def gateway_install_service( # pyright: ignore[reportUnusedFunction]
port: int | None = typer.Option(None, "--port", "-p", help="Gateway port"),
workspace: str | None = typer.Option(None, "--workspace", "-w", help="Workspace directory"),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Verbose output"),
@@ -302,7 +301,7 @@ def create_gateway_app(
raise typer.Exit(1)
@gateway_app.command("uninstall-service")
def gateway_uninstall_service(
def gateway_uninstall_service( # pyright: ignore[reportUnusedFunction]
name: str = typer.Option("nanobot-gateway", "--name", help="Service name"),
manager: ServiceManagerKind = typer.Option("auto", "--manager", help="auto, systemd, or launchd"),
dry_run: bool = typer.Option(False, "--dry-run", help="Print actions without uninstalling"),
+1
View File
@@ -581,6 +581,7 @@ def _run_gateway(
webui_runtime_model_name=_webui_runtime_model_name,
webui_cron_pending_job_ids=agent.pending_cron_job_ids_for_session,
webui_local_trigger_pending_ids=agent.pending_local_trigger_ids_for_session,
webui_cancel_active_turn=getattr(agent, "cancel_active_turn", None),
webui_static_dist=webui_static_dist,
webui_runtime_surface=webui_runtime_surface,
webui_runtime_capabilities=webui_runtime_capabilities,
+16 -11
View File
@@ -1,7 +1,5 @@
"""Interactive onboarding questionnaire for nanobot."""
# pyright: reportMissingTypeStubs=false, reportUnusedFunction=false
import asyncio
import json
import types
@@ -206,35 +204,36 @@ def _select_with_back(
# Key bindings
bindings = KeyBindings()
# KeyBindings consumes these handlers through decorator registration.
@bindings.add(Keys.Up)
def _up(event: KeyPressEvent) -> None:
def _up(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
nonlocal selected_index
selected_index = (selected_index - 1) % len(choices)
event.app.invalidate()
@bindings.add(Keys.Down)
def _down(event: KeyPressEvent) -> None:
def _down(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
nonlocal selected_index
selected_index = (selected_index + 1) % len(choices)
event.app.invalidate()
@bindings.add(Keys.Enter)
def _enter(event: KeyPressEvent) -> None:
def _enter(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
state["result"] = choices[selected_index]
event.app.exit()
@bindings.add("escape")
def _escape(event: KeyPressEvent) -> None:
def _escape(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
state["result"] = _BACK_PRESSED
event.app.exit()
@bindings.add(Keys.Left)
def _left(event: KeyPressEvent) -> None:
def _left(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
state["result"] = _BACK_PRESSED
event.app.exit()
@bindings.add(Keys.ControlC)
def _ctrl_c(event: KeyPressEvent) -> None:
def _ctrl_c(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
state["result"] = None
event.app.exit()
@@ -532,8 +531,9 @@ def _input_back_key_bindings() -> KeyBindings:
"""Return key bindings that make Escape behave like a local back action."""
bindings = KeyBindings()
# KeyBindings consumes this handler through decorator registration.
@bindings.add("escape")
def _escape(event: KeyPressEvent) -> None:
def _escape(event: KeyPressEvent) -> None: # pyright: ignore[reportUnusedFunction]
event.app.exit(result=_BACK_PRESSED)
return bindings
@@ -1668,7 +1668,11 @@ def _quick_start_oauth_login(config: Config, provider_name: str) -> bool:
return False
try:
from oauth_cli_kit import get_token, login_oauth_interactive
# oauth-cli-kit does not publish type information.
from oauth_cli_kit import ( # pyright: ignore[reportMissingTypeStubs]
get_token,
login_oauth_interactive,
)
except ImportError:
console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]")
return False
@@ -1709,7 +1713,8 @@ def _quick_start_oauth_is_authenticated(config: Config, provider_name: str) -> b
if provider_name != "openai_codex":
return False
try:
from oauth_cli_kit import get_token
# oauth-cli-kit does not publish type information.
from oauth_cli_kit import get_token # pyright: ignore[reportMissingTypeStubs]
proxy = _quick_start_codex_proxy(config)
token = get_token(proxy=proxy)
+2 -11
View File
@@ -203,16 +203,7 @@ async def cmd_stop(ctx: CommandContext) -> OutboundMessage:
"""Cancel all active tasks and subagents for the session."""
loop = ctx.loop
msg = ctx.msg
total = await loop._cancel_active_tasks(ctx.key) # pyright: ignore[reportPrivateUsage]
# Also drain pending queue to prevent mid-turn injection deadlock
pending = loop._pending_queues.pop(ctx.key, None) # pyright: ignore[reportPrivateUsage]
if pending is not None:
while not pending.empty():
try:
pending.get_nowait()
total += 1
except Exception:
break
total = await loop.cancel_active_turn(ctx.key)
content = f"Stopped {total} task(s)." if total else "No active task to stop."
return OutboundMessage(
channel=msg.channel, chat_id=msg.chat_id, content=content,
@@ -301,7 +292,7 @@ async def cmd_status(ctx: CommandContext) -> OutboundMessage:
async def cmd_new(ctx: CommandContext) -> OutboundMessage:
"""Stop active task and start a fresh session."""
loop = ctx.loop
await loop._cancel_active_tasks(ctx.key) # pyright: ignore[reportPrivateUsage]
await loop.cancel_active_turn(ctx.key)
session = ctx.session or loop.sessions.get_or_create(ctx.key)
snapshot = session.messages[session.last_consolidated:]
runtime = None
+28 -10
View File
@@ -504,6 +504,7 @@ class Config(BaseSettings):
model_normalized = model_lower.replace("-", "_")
model_prefix = model_lower.split("/", 1)[0] if "/" in model_lower else ""
normalized_prefix = model_prefix.replace("-", "_")
prefixed_provider = find_by_name(model_prefix) if model_prefix else None
def _kw_matches(kw: str) -> bool:
kw = kw.lower()
@@ -533,6 +534,22 @@ class Config(BaseSettings):
continue
p = getattr(self.providers, spec.name, None)
if p and any(_kw_matches(kw) for kw in spec.keywords):
# Local providers (Ollama, vLLM, …) keep model-family keywords
# like "nemotron" or "llama" to enable bare-model auto-routing,
# but those keywords collide with cloud-hosted variants of the
# same family (e.g. `nvidia/nemotron-...` via OpenRouter). Only
# honor a local keyword match when the user has actually
# configured that local endpoint via `api_base` — mirrors the
# gate already used by the local-fallback loop below.
if spec.is_local:
# A qualified model belongs to its explicit provider or a
# gateway fallback, never to a different local provider
# whose model-family keyword happens to match.
foreign_prefix = bool(
prefixed_provider is not None and prefixed_provider.name != spec.name
)
if not p.api_base or foreign_prefix:
continue
if spec.is_oauth or spec.is_local or spec.is_direct or p.api_key:
return p, spec.name
@@ -541,16 +558,17 @@ class Config(BaseSettings):
# Prefer providers whose detect_by_base_keyword matches the configured api_base
# (e.g. Ollama's "11434" in "http://localhost:11434") over plain registry order.
local_fallback: tuple[ProviderConfig, str] | None = None
for spec in PROVIDERS:
if not spec.is_local:
continue
p = getattr(self.providers, spec.name, None)
if not (p and p.api_base):
continue
if spec.detect_by_base_keyword and spec.detect_by_base_keyword in p.api_base:
return p, spec.name
if local_fallback is None:
local_fallback = (p, spec.name)
if prefixed_provider is None:
for spec in PROVIDERS:
if not spec.is_local:
continue
p = getattr(self.providers, spec.name, None)
if not (p and p.api_base):
continue
if spec.detect_by_base_keyword and spec.detect_by_base_keyword in p.api_base:
return p, spec.name
if local_fallback is None:
local_fallback = (p, spec.name)
if local_fallback:
return local_fallback
+34 -28
View File
@@ -163,9 +163,13 @@ class CronService:
self._store: CronStore | None = None
self._timer_task: asyncio.Task[None] | None = None
self._running = False
self._timer_active = False
self._active_executions = 0
self.max_sleep_ms = max_sleep_ms
def _should_persist_store(self) -> bool:
"""Return whether this instance currently owns the live store."""
return self._running or self._active_executions > 0
def _is_unbound_agent_job(self, job: CronJob) -> bool:
return job.payload.kind == "agent_turn" and not is_bound_cron_job(job)
@@ -278,23 +282,24 @@ class CronService:
logger.exception("load action line error")
continue
self._store.jobs = list(jobs_map.values()) # pyright: ignore[reportOptionalMemberAccess]
if self._running and changed:
if self._should_persist_store() and changed:
self._action_path.write_text("", encoding="utf-8")
self._save_store()
return
def _load_store(self) -> CronStore | None:
def _load_store(self, *, reload_during_execution: bool = False) -> CronStore | None:
"""Load jobs from disk. Reloads automatically if file was modified externally.
- Reload every time because it needs to merge operations on the jobs object from other instances.
- During _on_timer execution, return the existing store to prevent concurrent
- During job execution, return the existing store to prevent concurrent
_load_store calls (e.g. from list_jobs polling) from replacing it mid-execution.
The first execution explicitly reloads once when it takes ownership.
- When the on-disk store exists but is unreadable: keep using the
previous in-memory ``self._store`` if we already have one (so a
transient corruption does not drop live jobs); only the very first
load (during ``start``) can return ``None`` to signal an unrecoverable
state to the caller.
"""
if self._timer_active and self._store:
if self._active_executions > 0 and self._store and not reload_during_execution:
return self._store
loaded = self._load_jobs()
if loaded is None:
@@ -307,12 +312,12 @@ class CronService:
jobs, version = loaded
self._store = CronStore(version=version, jobs=jobs)
self._merge_action()
if self._enforce_store_agent_bindings() and self._running:
if self._enforce_store_agent_bindings() and self._should_persist_store():
self._save_store()
return self._store
def _require_store(self) -> CronStore:
def _require_store(self, *, reload_during_execution: bool = False) -> CronStore:
"""Return a usable store or raise a clear error.
``_load_store`` deliberately returns ``None`` when the first load sees
@@ -322,7 +327,7 @@ class CronService:
``AttributeError`` and, more importantly, prevents follow-up saves from
treating a corrupt store as an empty one.
"""
store = self._load_store()
store = self._load_store(reload_during_execution=reload_during_execution)
if store is None:
raise RuntimeError(
f"cron store at {self.store_path} could not be loaded and was preserved "
@@ -504,19 +509,20 @@ class CronService:
async def _on_timer(self) -> None:
"""Handle timer tick - run due jobs."""
self._load_store()
# If a hot reload found a corrupt store on disk, ``self._store`` may
# still hold the previous, known-good in-memory snapshot. Keep using
# it rather than crashing the timer or wiping live jobs.
if not self._store:
self._arm_timer()
return
self._timer_active = True
reload_store = self._active_executions == 0
self._active_executions += 1
try:
store = self._load_store(reload_during_execution=reload_store)
# If a hot reload found a corrupt store on disk, ``self._store`` may
# still hold the previous, known-good in-memory snapshot. Keep using
# it rather than crashing the timer or wiping live jobs.
if store is None:
self._arm_timer()
return
now = _now_ms()
due_jobs = [
j for j in self._store.jobs
j for j in store.jobs
if j.enabled and j.state.next_run_at_ms and now >= j.state.next_run_at_ms
]
@@ -525,7 +531,7 @@ class CronService:
self._save_store()
finally:
self._timer_active = False
self._active_executions -= 1
self._arm_timer()
async def _execute_job(self, job: CronJob) -> None:
@@ -657,7 +663,7 @@ class CronService:
)
_normalize_agent_turn_job(job)
self._enforce_agent_binding(job)
if self._running:
if self._should_persist_store():
store = self._require_store()
store.jobs.append(job)
self._save_store()
@@ -697,7 +703,7 @@ class CronService:
removed = len(store.jobs) < before
if removed:
if self._running:
if self._should_persist_store():
self._save_store()
self._arm_timer()
else:
@@ -719,7 +725,7 @@ class CronService:
job.state.next_run_at_ms = _compute_next_run(job.schedule, _now_ms())
else:
job.state.next_run_at_ms = None
if self._running:
if self._should_persist_store():
self._save_store()
self._arm_timer()
else:
@@ -775,7 +781,7 @@ class CronService:
else:
job.state.next_run_at_ms = None
if self._running:
if self._should_persist_store():
self._save_store()
self._arm_timer()
else:
@@ -786,10 +792,10 @@ class CronService:
async def run_job(self, job_id: str, force: bool = False) -> bool:
"""Manually run a job without disturbing the service's running state."""
was_running = self._running
self._running = True
reload_store = self._active_executions == 0
self._active_executions += 1
try:
store = self._require_store()
store = self._require_store(reload_during_execution=reload_store)
for job in store.jobs:
if job.id == job_id:
if self._is_unbound_agent_job(job):
@@ -803,8 +809,8 @@ class CronService:
return True
return False
finally:
self._running = was_running
if was_running:
self._active_executions -= 1
if self._running and self._active_executions == 0:
self._arm_timer()
def get_job(self, job_id: str) -> CronJob | None:
+28
View File
@@ -157,6 +157,7 @@ class Session:
metadata: dict[str, Any] = field(default_factory=dict)
last_consolidated: int = 0 # Number of messages already consolidated to files
provider_state: ProviderConversationState | None = field(default=None, repr=False)
transient: bool = field(default=False, repr=False, compare=False)
def __post_init__(self) -> None:
if not isinstance(cast(object, self.metadata), dict):
@@ -964,6 +965,7 @@ class SessionManager:
self._cache: OrderedDict[str, Session] = OrderedDict()
# Preserve identity for sessions held by active callers without retaining idle ones.
self._overflow_cache: WeakValueDictionary[str, Session] = WeakValueDictionary()
self._transient_sessions: dict[str, Session] = {}
self._max_cached_sessions = SESSION_CACHE_MAX_SIZE
self._file_cap_archiver: Callable[..., None] | None = None
@@ -977,6 +979,10 @@ class SessionManager:
self._overflow_cache[key] = evicted
def _cached(self, key: str) -> Session | None:
transient = self._transient_sessions.get(key)
if transient is not None:
return transient
session = self._cache.get(key)
if session is not None:
self._cache.move_to_end(key)
@@ -1053,6 +1059,24 @@ class SessionManager:
self._remember(session)
return session
def get_or_create_transient(self, key: str) -> Session:
"""Return an active in-memory session that can never reach the store."""
session = self._transient_sessions.get(key)
if session is None:
self._cache.pop(key, None)
self._overflow_cache.pop(key, None)
session = Session(key=key, transient=True)
self._transient_sessions[key] = session
return session
def is_transient_active(self, key: str) -> bool:
"""Return whether *key* still accepts transient turns."""
return key in self._transient_sessions
def discard_transient(self, key: str) -> bool:
"""Forget all transient contents without retaining a discarded-key tombstone."""
return self._transient_sessions.pop(key, None) is not None
def _load(self, key: str) -> Session | None:
return self._store.load(key)
@@ -1066,6 +1090,9 @@ class SessionManager:
def save(self, session: Session, *, fsync: bool = False) -> None:
"""Persist a session and retain it in the cache."""
if session.transient is True:
return
archiver = self._file_cap_archiver
if archiver is not None:
session.enforce_file_cap(
@@ -1098,6 +1125,7 @@ class SessionManager:
def invalidate(self, key: str) -> None:
"""Remove a session from the in-memory cache."""
self._transient_sessions.pop(key, None)
self._cache.pop(key, None)
self._overflow_cache.pop(key, None)
+10
View File
@@ -334,6 +334,16 @@ def clear_websocket_turn_if_current(
return False
def clear_websocket_turns(chat_id: str) -> int:
"""Clear every in-memory lifecycle owner for a discarded chat."""
turns = _WEBSOCKET_ACTIVE_TURNS.pop(chat_id, None)
count = len(turns) if turns is not None else 0
_WEBSOCKET_TURN_WALL_STARTED_AT.pop(chat_id, None)
_WEBSOCKET_TURN_IDS.pop(chat_id, None)
_WEBSOCKET_TURN_OWNERS.pop(chat_id, None)
return count
def build_bus_progress_callback(
bus: MessageBus,
msg: InboundMessage,
+5 -1
View File
@@ -2,9 +2,10 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable
from typing import TYPE_CHECKING, Any
from loguru import logger as default_logger
@@ -38,6 +39,7 @@ class GatewayServices:
local_trigger_store: LocalTriggerStore | None
cron_pending_job_ids: Callable[[str], set[str]] | None
local_trigger_pending_ids: Callable[[str], set[str]] | None
cancel_active_turn: Callable[[str], Awaitable[int]] | None
def build_gateway_services(
@@ -56,6 +58,7 @@ def build_gateway_services(
local_trigger_store: LocalTriggerStore | None = None,
cron_pending_job_ids: Callable[[str], set[str]] | None = None,
local_trigger_pending_ids: Callable[[str], set[str]] | None = None,
cancel_active_turn: Callable[[str], Awaitable[int]] | None = None,
channel_feature_action: Callable[..., Any] | None = None,
channel_runtime_status: Callable[[], dict[str, Any]] | None = None,
skill_state_action: Callable[[set[str]], None] | None = None,
@@ -117,4 +120,5 @@ def build_gateway_services(
local_trigger_store=local_trigger_store,
cron_pending_job_ids=cron_pending_job_ids,
local_trigger_pending_ids=local_trigger_pending_ids,
cancel_active_turn=cancel_active_turn,
)
+52
View File
@@ -592,6 +592,58 @@ class TestPrepareSession:
assert summary is not None
assert "Cold summary." in summary
def test_cold_path_tolerates_malformed_last_active(self):
"""A malformed persisted last_active must not raise on the turn path.
prepare_session runs from _compact_session on every turn. Persisted
_last_summary can be hand-edited or written by another version, so a bad
last_active should degrade gracefully (mirror estimate_session_prompt_tokens
and _archive) instead of crashing the turn.
"""
ac = _make_autocompact(ttl=0)
fallback = datetime(2026, 1, 2, 3, 4, 5)
session = _make_session(
metadata={
"_last_summary": {"text": "Cold summary.", "last_active": "not-a-date"},
},
updated_at=fallback,
)
result_session, summary = ac.prepare_session(session, "cli:test")
assert result_session is session
assert summary is not None
assert "Cold summary." in summary
assert fallback.isoformat() in summary
def test_cold_path_tolerates_missing_last_active(self):
"""A _last_summary dict without last_active must not raise."""
ac = _make_autocompact(ttl=0)
fallback = datetime(2026, 1, 2, 3, 4, 5)
session = _make_session(
metadata={"_last_summary": {"text": "Cold summary."}},
updated_at=fallback,
)
result_session, summary = ac.prepare_session(session, "cli:test")
assert result_session is session
assert summary is not None
assert "Cold summary." in summary
assert fallback.isoformat() in summary
def test_cold_path_missing_text_returns_none(self):
"""A _last_summary without a non-empty string text yields no summary."""
ac = _make_autocompact()
session = _make_session(metadata={
"_last_summary": {"last_active": datetime(2026, 1, 1).isoformat()},
})
result_session, summary = ac.prepare_session(session, "cli:test")
assert result_session is session
assert summary is None
def test_no_summary_available_returns_none(self):
"""When no summary is available, should return (session, None)."""
ac = _make_autocompact()
+14
View File
@@ -15,6 +15,20 @@ def _builder(tmp_path: Path, **kw) -> ContextBuilder:
return ContextBuilder(workspace=tmp_path, **kw)
def test_conversation_only_messages_omit_the_system_prompt(tmp_path) -> None:
(tmp_path / "AGENTS.md").write_text("SECRET PROJECT INSTRUCTIONS", encoding="utf-8")
builder = _builder(tmp_path)
messages = builder.build_messages(
[],
"hello",
conversation_only=True,
)
assert messages == [{"role": "user", "content": "hello"}]
assert "SECRET PROJECT INSTRUCTIONS" not in str(messages)
# ---------------------------------------------------------------------------
# _merge_message_content (static)
# ---------------------------------------------------------------------------
+18
View File
@@ -579,3 +579,21 @@ def test_history_skips_non_dict_jsonl_lines(tmp_path: Path) -> None:
}]
next_cursor = memory.append_history("next", session_key="cli:t")
assert next_cursor == 2
def test_raw_archive_handles_none_timestamp_and_missing_role(tmp_path: Path) -> None:
"""raw_archive and _format_messages must safely format messages with None timestamp or missing role.
Prevents TypeError on NoneType[:16] slicing and KeyError on missing 'role'
when raw-dumping unconsolidated history entries without timestamps or role fields.
"""
memory = MemoryStore(tmp_path)
messages = [
{"content": "message with none timestamp", "timestamp": None, "role": "user"},
{"content": "message with int timestamp", "timestamp": 1720000000, "role": "assistant"},
{"content": "message with missing role", "timestamp": "2026-07-28T12:00:00"},
]
memory.raw_archive(messages, session_key="cli:test")
raw_history = memory.history_file.read_text(encoding="utf-8")
assert "[?] USER: message with none timestamp" in raw_history
assert "[1720000000] ASSISTANT: message with int timestamp" in raw_history
assert "[2026-07-28T12:00] UNKNOWN: message with missing role" in raw_history
+42
View File
@@ -111,8 +111,50 @@ class TestHandleStop:
assert all(e.is_set() for e in events)
assert "2 task" in out.content
@pytest.mark.asyncio
async def test_cancel_active_turn_discards_pending_followups(self):
from nanobot.bus.events import InboundMessage
loop, _ = _make_loop()
pending = asyncio.Queue()
pending.put_nowait(
InboundMessage(channel="test", sender_id="u1", chat_id="c1", content="next")
)
loop._pending_queues["test:c1"] = pending
assert await loop.cancel_active_turn("test:c1") == 1
assert "test:c1" not in loop._pending_queues
class TestDispatch:
@pytest.mark.asyncio
async def test_run_drops_deactivated_transient_message(self):
from nanobot.bus.events import InboundMessage
loop, bus = _make_loop()
msg = InboundMessage(
channel="websocket",
sender_id="u1",
chat_id="temporary-test",
content="private",
session_key_override="websocket:temporary-test",
transient_session=True,
)
async def consume_once():
loop.stop()
return msg
bus.consume_inbound = AsyncMock(side_effect=consume_once)
loop.sessions.is_transient_active.return_value = False
loop._dispatch = AsyncMock()
loop.close_mcp = AsyncMock()
loop._running = True
await loop.run()
loop._dispatch.assert_not_awaited()
@pytest.mark.asyncio
async def test_run_logs_and_continues_after_leaked_cancelled_error(self, monkeypatch):
loop, bus = _make_loop()
+169
View File
@@ -0,0 +1,169 @@
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.providers.base import GenerationSettings, LLMResponse
from nanobot.runtime_context import RuntimeContextBlock
from nanobot.session.manager import SessionManager
@pytest.mark.asyncio
async def test_temporary_chat_reuses_memory_only_history_without_tools(tmp_path) -> None:
(tmp_path / "AGENTS.md").write_text("private project instruction", encoding="utf-8")
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.generation = GenerationSettings()
provider.chat_with_retry = AsyncMock(
side_effect=[
LLMResponse(content="first answer", usage={}),
LLMResponse(content="second answer", usage={}),
]
)
loop = AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="test-model",
unified_session=True,
)
key = "websocket:temporary-test"
loop.sessions.get_or_create_transient(key)
for content in ("first question", "second question"):
response = await loop._process_message(
InboundMessage(
channel="websocket",
sender_id="user",
chat_id="temporary-test",
content=content,
session_key_override=key,
transient_session=True,
)
)
assert response is not None
first_call, second_call = provider.chat_with_retry.await_args_list
assert first_call.kwargs["tools"] == []
assert second_call.kwargs["tools"] == []
assert all(
message["role"] != "system"
for call in (first_call, second_call)
for message in call.kwargs["messages"]
)
assert "private project instruction" not in str(first_call.kwargs["messages"])
assert str(tmp_path) not in str(first_call.kwargs["messages"])
assert "first answer" in str(second_call.kwargs["messages"])
transient = loop.sessions.get_cached(key)
assert transient is not None
assert [message["role"] for message in transient.messages] == [
"user",
"assistant",
"user",
"assistant",
]
assert loop.sessions.read_session_file(key) is None
assert SessionManager(tmp_path).read_session_file(key) is None
@pytest.mark.asyncio
async def test_temporary_follow_up_does_not_resolve_runtime_context(tmp_path) -> None:
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.generation = GenerationSettings()
provider.chat_with_retry = AsyncMock(
side_effect=[
LLMResponse(content="first answer", usage={}),
LLMResponse(content="second answer", usage={}),
]
)
loop = AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="test-model",
)
runtime_context_provider = AsyncMock(
return_value=RuntimeContextBlock(
source="project",
content="SECRET LOCAL PROJECT CONTEXT",
)
)
loop.register_runtime_context_provider(runtime_context_provider)
key = "websocket:temporary-follow-up"
session = loop.sessions.get_or_create_transient(key)
pending_queue: asyncio.Queue[InboundMessage] = asyncio.Queue()
await pending_queue.put(
InboundMessage(
channel="websocket",
sender_id="user",
chat_id="temporary-follow-up",
content="follow up",
session_key_override=key,
transient_session=True,
)
)
_, _, messages, _, _ = await loop._run_agent_loop(
[{"role": "user", "content": "first question"}],
runtime=loop.llm_runtime(),
session=session,
channel="websocket",
chat_id="temporary-follow-up",
session_key=key,
pending_queue=pending_queue,
tools=ToolRegistry(),
)
runtime_context_provider.assert_not_awaited()
assert "SECRET LOCAL PROJECT CONTEXT" not in str(messages)
@pytest.mark.asyncio
async def test_discarding_active_temporary_chat_does_not_create_durable_session(
tmp_path,
) -> None:
provider_started = asyncio.Event()
provider = MagicMock()
provider.get_default_model.return_value = "test-model"
provider.generation = GenerationSettings()
async def block_provider(**_kwargs):
provider_started.set()
await asyncio.Event().wait()
provider.chat_with_retry = AsyncMock(side_effect=block_provider)
loop = AgentLoop(
bus=MessageBus(),
provider=provider,
workspace=tmp_path,
model="test-model",
)
key = "websocket:temporary-cancelled"
loop.sessions.get_or_create_transient(key)
message = InboundMessage(
channel="websocket",
sender_id="user",
chat_id="temporary-cancelled",
content="private",
session_key_override=key,
transient_session=True,
)
task = asyncio.create_task(loop._dispatch(message))
active_tasks = loop._active_tasks.setdefault(key, set())
active_tasks.add(task)
task.add_done_callback(active_tasks.discard)
await provider_started.wait()
assert loop.sessions.discard_transient(key)
assert await loop.cancel_active_turn(key) == 1
assert loop.sessions.get_cached(key) is None
assert loop.sessions.flush_all() == 0
assert loop.sessions.read_session_file(key) is None
+2 -2
View File
@@ -253,7 +253,7 @@ class TestCmdNewUnifiedSession:
loop = SimpleNamespace(
sessions=sessions,
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
_cancel_active_tasks=AsyncMock(return_value=0),
cancel_active_turn=AsyncMock(return_value=0),
llm_runtime=MagicMock(return_value=MagicMock()),
schedule_background=lambda coro: asyncio.ensure_future(coro),
)
@@ -301,7 +301,7 @@ class TestCmdNewUnifiedSession:
loop = SimpleNamespace(
sessions=sessions,
consolidator=SimpleNamespace(archive=AsyncMock(return_value=True)),
_cancel_active_tasks=AsyncMock(return_value=0),
cancel_active_turn=AsyncMock(return_value=0),
runtime_for_session=MagicMock(return_value=MagicMock()),
schedule_background=lambda coro: asyncio.ensure_future(coro),
)
+57
View File
@@ -1160,6 +1160,63 @@ def test_config_falls_back_to_vllm_when_ollama_not_configured():
assert config.get_api_base() == "http://localhost:8000"
def test_config_cloud_nemotron_is_not_hijacked_by_unconfigured_ollama():
"""`nvidia/nemotron-*` via a gateway must not route to Ollama when no
Ollama endpoint is configured. Ollama keeps "nemotron" in its keywords
for bare-model auto-routing (PR #1863), which previously hijacked
cloud-hosted nemotron variants and silently sent traffic to
http://localhost:11434/v1."""
config = Config.model_validate(
{
"agents": {
"defaults": {
"provider": "auto",
"model": "nvidia/nemotron-3-super-120b-a12b",
}
},
"providers": {"openrouter": {"apiKey": "sk-or-test"}},
}
)
assert config.get_provider_name() == "openrouter"
assert config.get_api_base() == "https://openrouter.ai/api/v1"
def test_config_bare_nemotron_still_auto_routes_to_configured_ollama():
"""Preserves PR #1863 intent: when the user has actually configured an
Ollama endpoint, a bare nemotron model still auto-routes there."""
config = Config.model_validate(
{
"agents": {"defaults": {"provider": "auto", "model": "nemotron-3-nano"}},
"providers": {"ollama": {"apiBase": "http://localhost:11434/v1"}},
}
)
assert config.get_provider_name() == "ollama"
assert config.get_api_base() == "http://localhost:11434/v1"
def test_config_cloud_nemotron_is_not_hijacked_by_configured_ollama():
"""An explicit cloud namespace takes precedence over local keywords."""
config = Config.model_validate(
{
"agents": {
"defaults": {
"provider": "auto",
"model": "nvidia/nemotron-3-super-120b-a12b",
}
},
"providers": {
"ollama": {"apiBase": "http://localhost:11434/v1"},
"openrouter": {"apiKey": "sk-or-test"},
},
}
)
assert config.get_provider_name() == "openrouter"
assert config.get_api_base() == "https://openrouter.ai/api/v1"
def test_openai_compat_provider_passes_model_through():
from nanobot.providers.openai_compat_provider import OpenAICompatProvider
+1 -1
View File
@@ -109,7 +109,7 @@ class TestMidTurnCommandDispatchedDirectly:
loop.sessions.save = MagicMock()
loop.sessions.invalidate = MagicMock()
loop.schedule_background = MagicMock()
loop._cancel_active_tasks = AsyncMock(return_value=0)
loop.cancel_active_turn = AsyncMock(return_value=0)
return loop
@pytest.fixture()
+5 -17
View File
@@ -1,6 +1,5 @@
"""Test cmd_stop drains pending queue to prevent mid-turn injection deadlock."""
import asyncio
from unittest.mock import AsyncMock, MagicMock
import pytest
@@ -14,13 +13,7 @@ from nanobot.command.router import CommandContext
async def test_cmd_stop_drains_pending_queue():
"""cmd_stop should drain pending queue in addition to cancelling active tasks."""
mock_loop = MagicMock()
mock_loop._cancel_active_tasks = AsyncMock(return_value=1)
mock_loop._pending_queues = {}
pending = asyncio.Queue()
await pending.put("msg1")
await pending.put("msg2")
mock_loop._pending_queues["test-session"] = pending
mock_loop.cancel_active_turn = AsyncMock(return_value=3)
ctx = CommandContext(
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
@@ -34,18 +27,14 @@ async def test_cmd_stop_drains_pending_queue():
assert isinstance(result, OutboundMessage)
assert "Stopped 3 task(s)" in result.content # 1 cancelled + 2 drained
assert "test-session" not in mock_loop._pending_queues
mock_loop.cancel_active_turn.assert_awaited_once_with("test-session")
@pytest.mark.asyncio
async def test_cmd_stop_with_empty_pending_queue():
"""cmd_stop should work correctly when pending queue is empty."""
mock_loop = MagicMock()
mock_loop._cancel_active_tasks = AsyncMock(return_value=2)
mock_loop._pending_queues = {}
pending = asyncio.Queue()
mock_loop._pending_queues["test-session"] = pending
mock_loop.cancel_active_turn = AsyncMock(return_value=2)
ctx = CommandContext(
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
@@ -58,15 +47,14 @@ async def test_cmd_stop_with_empty_pending_queue():
result = await cmd_stop(ctx)
assert "Stopped 2 task(s)" in result.content
assert "test-session" not in mock_loop._pending_queues
mock_loop.cancel_active_turn.assert_awaited_once_with("test-session")
@pytest.mark.asyncio
async def test_cmd_stop_no_pending_queue():
"""cmd_stop should work when no pending queue exists."""
mock_loop = MagicMock()
mock_loop._cancel_active_tasks = AsyncMock(return_value=0)
mock_loop._pending_queues = {}
mock_loop.cancel_active_turn = AsyncMock(return_value=0)
ctx = CommandContext(
msg=MagicMock(channel="websocket", chat_id="test-chat", metadata={}),
+111
View File
@@ -600,6 +600,117 @@ async def test_run_job_preserves_running_service_state(tmp_path) -> None:
service.stop()
@pytest.mark.asyncio
async def test_manual_run_persists_completion_when_callback_lists_jobs(tmp_path) -> None:
store_path = tmp_path / "cron" / "jobs.json"
async def on_job(_job) -> None:
service.list_jobs(include_disabled=True)
await asyncio.sleep(0)
service = CronService(store_path, on_job=on_job)
job = service.add_job(
name="manual",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
**_bound_chat(),
)
assert await service.run_job(job.id) is True
state = json.loads(store_path.read_text())["jobs"][0]["state"]
assert state["lastStatus"] == "ok"
assert state["lastError"] is None
assert len(state["runHistory"]) == 1
assert state["runHistory"][0]["status"] == "ok"
@pytest.mark.asyncio
async def test_overlapping_manual_runs_preserve_stopped_service_state(tmp_path) -> None:
store_path = tmp_path / "cron" / "jobs.json"
entered = [asyncio.Event(), asyncio.Event()]
release = [asyncio.Event(), asyncio.Event()]
call_count = 0
async def on_job(_job) -> None:
nonlocal call_count
call_index = call_count
call_count += 1
entered[call_index].set()
await release[call_index].wait()
service = CronService(store_path, on_job=on_job)
jobs = [
service.add_job(
name=f"manual-{index}",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
**_bound_chat(str(index)),
)
for index in range(2)
]
first = asyncio.create_task(service.run_job(jobs[0].id))
await entered[0].wait()
second = asyncio.create_task(service.run_job(jobs[1].id))
try:
await entered[1].wait()
release[0].set()
assert await first is True
assert service._running is False
release[1].set()
assert await second is True
assert service._running is False
assert service._timer_task is None
states = {
item["name"]: item["state"]
for item in json.loads(store_path.read_text())["jobs"]
}
assert states["manual-0"]["lastStatus"] == "ok"
assert states["manual-1"]["lastStatus"] == "ok"
finally:
release[0].set()
release[1].set()
await asyncio.gather(first, second, return_exceptions=True)
service.stop()
@pytest.mark.asyncio
async def test_manual_run_does_not_restart_service_stopped_during_execution(tmp_path) -> None:
store_path = tmp_path / "cron" / "jobs.json"
entered = asyncio.Event()
release = asyncio.Event()
async def on_job(_job) -> None:
entered.set()
await release.wait()
service = CronService(store_path, on_job=on_job)
job = service.add_job(
name="manual-stop",
schedule=CronSchedule(kind="every", every_ms=60_000),
message="hello",
**_bound_chat(),
)
await service.start()
run = asyncio.create_task(service.run_job(job.id))
try:
await entered.wait()
service.stop()
release.set()
assert await run is True
assert service._running is False
assert service._timer_task is None
finally:
release.set()
await asyncio.gather(run, return_exceptions=True)
service.stop()
@pytest.mark.asyncio
async def test_running_service_honors_external_disable(tmp_path) -> None:
store_path = tmp_path / "cron" / "jobs.json"
+20
View File
@@ -73,3 +73,23 @@ def test_flush_all_includes_live_sessions_outside_strong_cache(tmp_path, monkeyp
assert manager.flush_all() == 2
assert set(saved) == {("test:active", True), ("test:other", True)}
def test_transient_session_never_reaches_store(tmp_path) -> None:
manager = SessionManager(tmp_path)
session = manager.get_or_create_transient("websocket:temporary-test")
session.add_message("user", "private")
manager.save(session, fsync=True)
assert manager.get_cached(session.key) is session
assert manager.read_session_file(session.key) is None
def test_transient_session_becomes_inactive_when_discarded(tmp_path) -> None:
manager = SessionManager(tmp_path)
session = manager.get_or_create_transient("websocket:temporary-test")
assert manager.discard_transient(session.key) is True
assert manager.is_transient_active(session.key) is False
assert manager.get_cached(session.key) is None
+48
View File
@@ -16,11 +16,13 @@ from nanobot.agent import context as agent_context
from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.context import RequestContext, bind_request_context, reset_request_context
from nanobot.agent.tools.exec_session import (
MAX_OUTPUT_CHARS,
ExecSessionManager,
ListExecSessionsTool,
WriteStdinTool,
_BoundedOutputBuffer,
_SessionPoll,
_truncate_output,
)
from nanobot.agent.tools.registry import is_tool_error_result
from nanobot.agent.tools.shell import ExecTool
@@ -227,6 +229,52 @@ def test_write_stdin_wait_for_keeps_aggregate_within_output_budget():
assert len(result) < 1100
def test_write_stdin_wait_for_searches_before_response_truncation():
async def run() -> tuple[str, list[int]]:
output = "A" * 1500 + "TARGET" + "B" * 1500
observed_limits: list[int] = []
async def write(
*,
session_id: str,
chars: str | None,
close_stdin: bool,
terminate: bool,
yield_time_ms: int,
max_output_chars: int,
owner_session_key: str | None,
) -> _SessionPoll:
del session_id, chars, close_stdin, terminate, yield_time_ms, owner_session_key
observed_limits.append(max_output_chars)
visible, truncated = _truncate_output(output, max_output_chars)
return _SessionPoll(
output=visible,
done=True,
exit_code=0,
truncated_chars=truncated,
)
manager = SimpleNamespace(write=AsyncMock(side_effect=write))
tool = WriteStdinTool(manager=manager)
result = await tool._wait_for_output(
session_id="session",
chars=None,
close_stdin=False,
terminate=False,
wait_for="TARGET",
wait_timeout_ms=1000,
max_output_chars=1000,
)
return result, observed_limits
result, observed_limits = asyncio.run(run())
assert observed_limits == [MAX_OUTPUT_CHARS]
assert "Wait target not observed" not in result
assert "(2,006 chars truncated from output)" in result
assert len(result) < 1100
def test_exec_one_shot_accepts_max_output_tokens_alias(tmp_path):
async def run() -> str:
tool = ExecTool(working_dir=str(tmp_path), timeout=5)
+150 -15
View File
@@ -8,7 +8,7 @@ import {
useState,
type ReactNode,
} from "react";
import { Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
import { Ghost, Moon, PanelLeft, ShieldCheck, Sun, X } from "lucide-react";
import { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry";
import { Sidebar } from "@/components/Sidebar";
@@ -37,6 +37,13 @@ import {
import { displayTitle } from "@/lib/chat-groups";
import { deriveTitle } from "@/lib/format";
import { NanobotClient } from "@/lib/nanobot-client";
import {
createTemporaryChatSession,
isQuickChatKey,
QUICK_CHAT_ID,
QUICK_CHAT_KEY,
quickChatSession,
} from "@/lib/quick-chat";
import { ClientProvider, useClient } from "@/providers/ClientProvider";
import type {
BootstrapResponse,
@@ -225,6 +232,9 @@ function readShellRoute(): ShellRoute {
if (path === "/skills") {
return { view: "skills", activeKey, settingsSection: "skills" };
}
if (path === "/quick-chat") {
return { view: "chat", activeKey: QUICK_CHAT_KEY, settingsSection: "overview" };
}
if (path.startsWith("/chat/")) {
const encoded = path.slice("/chat/".length);
try {
@@ -241,6 +251,7 @@ function readShellRoute(): ShellRoute {
function shellRouteHash(route: ShellRoute): string {
if (route.view === "chat") {
if (isQuickChatKey(route.activeKey)) return "#/quick-chat";
return route.activeKey
? `#/chat/${encodeURIComponent(route.activeKey)}`
: "#/new";
@@ -947,14 +958,24 @@ function Shell({
deleteChat,
getSessionAutomations,
} = useSessions();
const regularSessions = useMemo(
() => sessions.filter((session) => !isQuickChatKey(session.key)),
[sessions],
);
const quickSession = useMemo(
() => quickChatSession(sessions.find((session) => isQuickChatKey(session.key))),
[sessions],
);
const { state: sidebarState, update: updateSidebarState } =
useSidebarState(sessions, !loading);
useSidebarState(regularSessions, !loading);
const initialRouteRef = useRef<ShellRoute | null>(null);
if (!initialRouteRef.current) initialRouteRef.current = readShellRoute();
const [activeKey, setActiveKey] = useState<string | null>(
initialRouteRef.current.activeKey,
);
const [view, setView] = useState<ShellView>(initialRouteRef.current.view);
const [temporarySession, setTemporarySession] = useState<ChatSummary | null>(null);
const temporarySessionRef = useRef<ChatSummary | null>(null);
const [settingsInitialSection, setSettingsInitialSection] =
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
const [hostSidebarOpen, setHostSidebarOpen] =
@@ -1004,19 +1025,33 @@ function Shell({
const showHostChrome = effectiveRuntimeSurface === "native";
const showMainSidebar = view !== "settings";
const discardTemporaryChat = useCallback(() => {
const current = temporarySessionRef.current;
if (!current) return;
temporarySessionRef.current = null;
client.discardTemporaryChat(current.chatId);
setTemporarySession(null);
}, [client]);
const navigate = useCallback(
(route: ShellRoute, options?: { replace?: boolean }) => {
if (route.view !== "chat" || route.activeKey !== QUICK_CHAT_KEY) {
discardTemporaryChat();
}
setActiveKey(route.activeKey);
setView(route.view);
setSettingsInitialSection(route.settingsSection);
writeShellRoute(route, options?.replace);
},
[],
[discardTemporaryChat],
);
useEffect(() => {
const applyRoute = () => {
const route = readShellRoute();
if (route.view !== "chat" || route.activeKey !== QUICK_CHAT_KEY) {
discardTemporaryChat();
}
setActiveKey(route.activeKey);
setView(route.view);
setSettingsInitialSection(route.settingsSection);
@@ -1027,7 +1062,15 @@ function Shell({
};
window.addEventListener("hashchange", applyRoute);
return () => window.removeEventListener("hashchange", applyRoute);
}, []);
}, [discardTemporaryChat]);
useEffect(() => {
return client.onStatus((status) => {
if (status !== "open") discardTemporaryChat();
});
}, [client, discardTemporaryChat]);
useEffect(() => () => discardTemporaryChat(), [discardTemporaryChat]);
useEffect(() => {
let cancelled = false;
@@ -1114,8 +1157,11 @@ function Shell({
const activeSession = useMemo<ChatSummary | null>(() => {
if (!activeKey) return null;
if (isQuickChatKey(activeKey)) return temporarySession ?? quickSession;
return sessions.find((s) => s.key === activeKey) ?? null;
}, [sessions, activeKey]);
}, [sessions, activeKey, quickSession, temporarySession]);
const quickChatActive = isQuickChatKey(activeKey);
const temporaryChatActive = quickChatActive && temporarySession !== null;
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
const activeChatId = activeSession?.chatId ?? null;
@@ -1130,6 +1176,12 @@ function Shell({
});
}, [activeChatId]);
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
if (temporaryChatActive) {
return null;
}
if (quickChatActive) {
return workspaces?.default_scope ?? null;
}
if (activeChatId && workspaceOverrides[activeChatId]) {
return workspaceOverrides[activeChatId];
}
@@ -1141,6 +1193,8 @@ function Shell({
activeChatId,
activeSession?.workspaceScope,
draftWorkspaceScope,
quickChatActive,
temporaryChatActive,
workspaceOverrides,
workspaces?.default_scope,
]);
@@ -1161,7 +1215,10 @@ function Shell({
useEffect(() => {
if (loading) return;
const knownChatIds = new Set(sessions.map((session) => session.chatId));
const knownChatIds = new Set([
QUICK_CHAT_ID,
...sessions.map((session) => session.chatId),
]);
setUpdatedChatIds((current) => {
const next = new Set(
Array.from(current).filter((chatId) => knownChatIds.has(chatId)),
@@ -1176,6 +1233,7 @@ function Shell({
useEffect(() => {
if (loading || !activeKey) return;
if (isQuickChatKey(activeKey)) return;
if (sessions.some((session) => session.key === activeKey)) return;
const currentRoute = readShellRoute();
navigate(
@@ -1417,6 +1475,28 @@ function Shell({
setMobileSidebarOpen(false);
}, [navigate]);
const onOpenQuickChat = useCallback(() => {
setDraftWorkspaceScope(null);
setWorkspaceError(null);
setSessionSearchOpen(false);
navigate({
view: "chat",
activeKey: QUICK_CHAT_KEY,
settingsSection: "overview",
});
setMobileSidebarOpen(false);
}, [navigate]);
const onToggleTemporaryChat = useCallback(() => {
if (temporarySessionRef.current) {
discardTemporaryChat();
return;
}
const session = createTemporaryChatSession();
temporarySessionRef.current = session;
setTemporarySession(session);
}, [discardTemporaryChat]);
const onNewChatInProject = useCallback(
(projectPath: string, projectName: string) => {
const base = workspaces?.default_scope ?? activeWorkspaceScope;
@@ -1682,6 +1762,7 @@ function Shell({
setMobileSidebarOpen(false);
const nextKey = (() => {
if (!activeKey) return null;
if (isQuickChatKey(activeKey)) return activeKey;
if (sessions.some((session) => session.key === activeKey)) return activeKey;
return sessions[0]?.key ?? null;
})();
@@ -1773,7 +1854,10 @@ function Shell({
});
}, [client, t]);
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
const onTurnEnd = useDeferredTitleRefresh(
quickChatActive ? null : activeSession,
refresh,
);
const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return;
@@ -1863,11 +1947,39 @@ function Shell({
});
}, []);
const headerTitle = activeSession
const headerTitle = temporaryChatActive
? t("quickChat.temporary.title")
: quickChatActive
? t("sidebar.quickChat")
: activeSession
? sidebarState.title_overrides[activeSession.key] ||
activeSession.title ||
deriveTitle(activeSession.preview, t("chat.newChat"))
: t("app.brand");
: t("app.brand");
const temporaryChatAction = quickChatActive ? (
<Button
type="button"
size="sm"
variant="ghost"
aria-pressed={temporaryChatActive}
aria-label={
temporaryChatActive
? t("quickChat.temporary.exit")
: t("quickChat.temporary.enter")
}
onClick={onToggleTemporaryChat}
className={cn(
"host-no-drag h-8 rounded-full px-2.5 text-xs text-muted-foreground",
temporaryChatActive && "bg-foreground text-background hover:bg-foreground/90 hover:text-background",
)}
>
<Ghost className="mr-1.5 h-3.5 w-3.5" />
{temporaryChatActive
? t("quickChat.temporary.active")
: t("quickChat.temporary.enter")}
</Button>
) : undefined;
useEffect(() => {
if (view === "settings") {
@@ -1900,9 +2012,12 @@ function Shell({
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
const sidebarProps = {
sessions,
activeKey,
sessions: regularSessions,
activeKey: view === "chat" ? activeKey : null,
loading,
quickChatActive: view === "chat" && quickChatActive,
newChatActive: view === "chat" && activeKey === null,
onOpenQuickChat,
onNewChat,
onSelect: onSelectChat,
onRequestDelete,
@@ -2065,7 +2180,7 @@ function Shell({
<SessionSearchDialog
open
onOpenChange={setSessionSearchOpen}
sessions={sessions}
sessions={regularSessions}
activeKey={activeKey}
loading={loading}
titleOverrides={sidebarState.title_overrides}
@@ -2090,7 +2205,7 @@ function Shell({
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onForkChat={onForkChat}
onForkChat={quickChatActive ? undefined : onForkChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
@@ -2098,14 +2213,34 @@ function Shell({
hostChromeTitleInset={hostSidebarCollapsed}
hideHeader={false}
workspaceScope={activeWorkspaceScope}
workspaceDefaultScope={workspaces?.default_scope ?? null}
workspaceControls={workspaces?.controls ?? null}
workspaceDefaultScope={
temporaryChatActive ? null : workspaces?.default_scope ?? null
}
workspaceControls={
quickChatActive ? null : (workspaces?.controls ?? null)
}
workspaceScopeDisabled={activeChatRunning}
workspaceError={workspaceError}
onWorkspaceScopeChange={applyWorkspaceScope}
settingsSnapshot={settingsSnapshot}
onOpenModelSettings={onOpenModelSettings}
skills={skills}
allowConversationReset={!quickChatActive}
showSessionInfo={!quickChatActive}
emptyStateGreeting={
temporaryChatActive
? t("quickChat.temporary.greeting")
: quickChatActive
? t("quickChat.greeting")
: undefined
}
emptyStateDescription={
temporaryChatActive
? t("quickChat.temporary.description")
: undefined
}
temporary={temporaryChatActive}
headerAction={temporaryChatAction}
/>
</div>
{view !== "chat" && (
+11 -89
View File
@@ -1,7 +1,6 @@
import {
memo,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
@@ -25,6 +24,10 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
SIDEBAR_SELECTION_ITEM_CLASS,
SidebarSelectionHighlight,
} from "@/components/SidebarSelectionHighlight";
import { deriveTitle, relativeTime, visibleSessionPreview } from "@/lib/format";
import {
COLLAPSED_CHATS_VISIBLE_COUNT,
@@ -104,11 +107,7 @@ export const ChatList = memo(function ChatList({
}: ChatListProps) {
const { t } = useTranslation();
const [visibleLimit, setVisibleLimit] = useState(INITIAL_VISIBLE_SESSIONS);
const listContentRef = useRef<HTMLDivElement>(null);
const activeRowRef = useRef<HTMLDivElement>(null);
const activeHighlightRef = useRef<HTMLDivElement>(null);
const activeHighlightSurfaceRef = useRef<HTMLDivElement>(null);
const highlightVisibleRef = useRef(false);
const labels = useMemo<ChatGroupLabels>(() => ({
pinned: t("chat.groups.pinned"),
all: t("chat.groups.all"),
@@ -163,74 +162,6 @@ export const ChatList = memo(function ChatList({
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
}, [showArchived, sort]);
useLayoutEffect(() => {
let resetTransitionFrame: number | null = null;
const updateHighlight = () => {
const content = listContentRef.current;
const row = activeRowRef.current;
const highlight = activeHighlightRef.current;
const surface = activeHighlightSurfaceRef.current;
if (!highlight || !surface) return;
if (!content || !row) {
surface.style.opacity = "0";
surface.style.transform = "scale(0.97)";
highlightVisibleRef.current = false;
return;
}
const shouldFloatIn = !highlightVisibleRef.current;
if (shouldFloatIn) {
highlight.style.transitionProperty = "none";
}
const contentRect = content.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
highlight.style.width = `${rowRect.width}px`;
highlight.style.height = `${rowRect.height}px`;
highlight.style.transform = `translate3d(${rowRect.left - contentRect.left}px, ${
rowRect.top - contentRect.top
}px, 0)`;
if (shouldFloatIn) {
void highlight.offsetWidth;
}
surface.style.opacity = "1";
surface.style.transform = "scale(1)";
highlightVisibleRef.current = true;
if (shouldFloatIn) {
resetTransitionFrame = window.requestAnimationFrame(() => {
highlight.style.removeProperty("transition-property");
resetTransitionFrame = null;
});
}
};
updateHighlight();
const resizeObserver =
typeof ResizeObserver === "undefined"
? null
: new ResizeObserver(updateHighlight);
if (resizeObserver) {
if (listContentRef.current) resizeObserver.observe(listContentRef.current);
if (activeRowRef.current) resizeObserver.observe(activeRowRef.current);
}
window.addEventListener("resize", updateHighlight);
return () => {
if (resetTransitionFrame !== null) {
window.cancelAnimationFrame(resetTransitionFrame);
}
activeHighlightRef.current?.style.removeProperty("transition-property");
resizeObserver?.disconnect();
window.removeEventListener("resize", updateHighlight);
};
}, [activeKey, density, limitedGroups, showPreviews, showTimestamps]);
if (loading && sessions.length === 0) {
return (
<div className="px-3 py-6 text-[12px] text-muted-foreground">
@@ -256,8 +187,10 @@ export const ChatList = memo(function ChatList({
return (
<div className="h-full min-h-0 min-w-0 overflow-x-hidden overflow-y-auto overscroll-contain scrollbar-thin scrollbar-track-transparent">
<div
ref={listContentRef}
<SidebarSelectionHighlight
targetRef={activeRowRef}
activeId={activeKey}
scope="sessions"
data-chat-list-content
className="relative min-w-0 space-y-3 px-2 py-1.5"
>
@@ -333,7 +266,8 @@ export const ChatList = memo(function ChatList({
ref={active ? activeRowRef : undefined}
data-chat-row={s.key}
className={cn(
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px] transition-colors",
"group flex min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px]",
SIDEBAR_SELECTION_ITEM_CLASS,
compact ? "min-h-7" : "min-h-8",
active
? "text-sidebar-accent-foreground"
@@ -475,19 +409,7 @@ export const ChatList = memo(function ChatList({
</button>
</div>
) : null}
<div
ref={activeHighlightRef}
data-testid="active-chat-highlight"
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-0 !mt-0 transition-[transform,width,height] duration-300 ease-out will-change-transform motion-reduce:transition-none"
>
<div
ref={activeHighlightSurfaceRef}
data-testid="active-chat-highlight-surface"
className="h-full w-full scale-[0.97] rounded-xl bg-sidebar-foreground/[0.055] opacity-0 transition-[opacity,transform] duration-200 ease-out motion-reduce:transition-none dark:bg-white/[0.07]"
/>
</div>
</div>
</SidebarSelectionHighlight>
</div>
);
});
+51 -9
View File
@@ -1,8 +1,14 @@
import { useState, type ReactNode } from "react";
import {
type ReactNode,
type RefObject,
useRef,
useState,
} from "react";
import {
Archive,
Brain,
CalendarClock,
MessageCircle,
Menu,
Search,
Settings,
@@ -13,6 +19,10 @@ import { useTranslation } from "react-i18next";
import { ChatList } from "@/components/ChatList";
import { ConnectionBadge } from "@/components/ConnectionBadge";
import {
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
SidebarSelectionHighlight,
} from "@/components/SidebarSelectionHighlight";
import { Button } from "@/components/ui/button";
import type {
ChatSummary,
@@ -24,6 +34,9 @@ interface SidebarProps {
sessions: ChatSummary[];
activeKey: string | null;
loading: boolean;
quickChatActive: boolean;
newChatActive: boolean;
onOpenQuickChat: () => void;
onNewChat: () => void;
onSelect: (key: string) => void;
onRequestDelete: (key: string, label: string) => void;
@@ -82,6 +95,14 @@ export function Sidebar(props: SidebarProps) {
const collapsed = Boolean(props.collapsed);
const toggleLabel = t("thread.header.toggleSidebar");
const newChatShortcut = newChatShortcutLabel();
const activeActionRef = useRef<HTMLButtonElement>(null);
const activeActionId = props.quickChatActive
? "quick-chat"
: props.newChatActive
? "new-chat"
: props.activeUtility
? `utility:${props.activeUtility}`
: null;
return (
<nav
@@ -133,16 +154,29 @@ export function Sidebar(props: SidebarProps) {
)}
</div>
<div
<SidebarSelectionHighlight
targetRef={activeActionRef}
activeId={activeActionId}
scope="actions"
className={cn(
"space-y-1.5 px-2 pb-2",
"relative space-y-1.5 px-2 pb-2",
collapsed && "flex w-14 flex-col items-center px-0",
)}
>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.quickChat")}
onClick={props.onOpenQuickChat}
active={props.quickChatActive}
selectionRef={activeActionRef}
icon={<MessageCircle className="h-4 w-4" />}
/>
<SidebarActionButton
collapsed={collapsed}
label={t("sidebar.newChat")}
onClick={props.onNewChat}
active={props.newChatActive}
selectionRef={activeActionRef}
icon={<SquarePen className="h-4 w-4" />}
shortcut={newChatShortcut}
ariaKeyShortcuts="Meta+Shift+O Control+Shift+O"
@@ -159,6 +193,7 @@ export function Sidebar(props: SidebarProps) {
onClick={props.onOpenApps}
onIntent={props.onSettingsIntent}
active={props.activeUtility === "apps"}
selectionRef={activeActionRef}
icon={<Blocks className="h-4 w-4" />}
/>
<SidebarActionButton
@@ -167,6 +202,7 @@ export function Sidebar(props: SidebarProps) {
onClick={props.onOpenSkills}
onIntent={props.onSettingsIntent}
active={props.activeUtility === "skills"}
selectionRef={activeActionRef}
icon={<Brain className="h-4 w-4" />}
/>
<SidebarActionButton
@@ -175,6 +211,7 @@ export function Sidebar(props: SidebarProps) {
onClick={props.onOpenAutomations}
onIntent={props.onSettingsIntent}
active={props.activeUtility === "automations"}
selectionRef={activeActionRef}
icon={<CalendarClock className="h-4 w-4" />}
/>
{props.archivedCount ? (
@@ -185,7 +222,7 @@ export function Sidebar(props: SidebarProps) {
icon={<Archive className="h-4 w-4" />}
/>
) : null}
</div>
</SidebarSelectionHighlight>
<div
className={cn(
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden transition-opacity duration-200",
@@ -255,6 +292,7 @@ function SidebarActionButton({
shortcut,
ariaKeyShortcuts,
onIntent,
selectionRef,
}: {
collapsed: boolean;
label: string;
@@ -265,13 +303,15 @@ function SidebarActionButton({
shortcut?: string;
ariaKeyShortcuts?: string;
onIntent?: () => void;
selectionRef?: RefObject<HTMLButtonElement>;
}) {
const title = shortcut ? `${label} (${shortcut})` : collapsed ? label : undefined;
return (
<Button
ref={active ? selectionRef : undefined}
type="button"
variant="ghost"
variant={null}
aria-label={label}
aria-current={active ? "page" : undefined}
aria-keyshortcuts={ariaKeyShortcuts}
@@ -280,12 +320,14 @@ function SidebarActionButton({
onFocus={onIntent}
onPointerEnter={onIntent}
className={cn(
"touch-target group h-8 min-w-0 gap-2 overflow-hidden rounded-full font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
"touch-target group h-8 min-w-0 gap-2 overflow-hidden rounded-xl font-medium",
SIDEBAR_SELECTION_ACTION_ITEM_CLASS,
collapsed
? "w-9 justify-center gap-0 rounded-xl px-0"
? "w-9 justify-center gap-0 px-0"
: "w-full justify-start gap-2 px-3 text-[12.5px]",
active && "bg-sidebar-accent text-sidebar-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
active
? "text-sidebar-accent-foreground"
: "text-sidebar-foreground/85 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
className,
)}
>
@@ -0,0 +1,101 @@
import {
type HTMLAttributes,
type RefObject,
useLayoutEffect,
useRef,
} from "react";
interface SidebarSelectionHighlightProps extends HTMLAttributes<HTMLDivElement> {
targetRef: RefObject<HTMLElement>;
activeId: string | null;
scope: string;
}
export const SIDEBAR_SELECTION_ITEM_CLASS =
"relative z-[1] transition-[color] duration-150 ease-out motion-reduce:transition-none";
export const SIDEBAR_SELECTION_ACTION_ITEM_CLASS =
"relative z-[1] transition-[width,padding,color] [transition-duration:300ms,300ms,150ms] ease-out motion-reduce:transition-none";
export function SidebarSelectionHighlight({
targetRef,
activeId,
scope,
children,
...containerProps
}: SidebarSelectionHighlightProps) {
const containerRef = useRef<HTMLDivElement>(null);
const highlightRef = useRef<HTMLDivElement>(null);
const positionedRef = useRef(false);
useLayoutEffect(() => {
const highlight = highlightRef.current;
const container = containerRef.current;
const target = targetRef.current;
if (!highlight) return;
if (!activeId || !container || !target) {
highlight.style.opacity = "0";
positionedRef.current = false;
return;
}
let restoreTransitionFrame: number | null = null;
const position = () => {
const containerRect = container.getBoundingClientRect();
const targetRect = target.getBoundingClientRect();
if (targetRect.width === 0 || targetRect.height === 0) {
highlight.style.opacity = "0";
positionedRef.current = false;
return;
}
const firstPosition = !positionedRef.current;
if (firstPosition) highlight.style.transitionProperty = "none";
highlight.style.width = `${targetRect.width}px`;
highlight.style.height = `${targetRect.height}px`;
highlight.style.transform = `translate3d(${targetRect.left - containerRect.left}px, ${
targetRect.top - containerRect.top
}px, 0)`;
highlight.style.opacity = "1";
positionedRef.current = true;
if (firstPosition) {
restoreTransitionFrame = window.requestAnimationFrame(() => {
highlight.style.removeProperty("transition-property");
restoreTransitionFrame = null;
});
}
};
position();
const resizeObserver =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(position);
resizeObserver?.observe(container);
resizeObserver?.observe(target);
window.addEventListener("resize", position);
return () => {
if (restoreTransitionFrame !== null) {
window.cancelAnimationFrame(restoreTransitionFrame);
}
highlight?.style.removeProperty("transition-property");
resizeObserver?.disconnect();
window.removeEventListener("resize", position);
};
});
return (
<div {...containerProps} ref={containerRef}>
{children}
<div
ref={highlightRef}
data-testid={`${scope}-selection-highlight`}
data-active-id={activeId ?? undefined}
aria-hidden="true"
className="pointer-events-none absolute left-0 top-0 z-0 !mt-0 rounded-xl bg-sidebar-foreground/[0.055] opacity-0 transition-[transform,width,height] duration-300 ease-out will-change-transform motion-reduce:transition-none dark:bg-white/[0.07]"
/>
</div>
);
}
+16 -4
View File
@@ -65,6 +65,10 @@ import { useTranslation } from "react-i18next";
import { channelUiPresentation } from "@/channel-plugins/registry";
import { LanguageSwitcher } from "@/components/LanguageSwitcher";
import {
SIDEBAR_SELECTION_ITEM_CLASS,
SidebarSelectionHighlight,
} from "@/components/SidebarSelectionHighlight";
import { SkillsCatalogSettings } from "@/components/settings/SkillsCatalogSettings";
import { TokenUsageHeatmap } from "@/components/settings/TokenUsageHeatmap";
import { ToggleButton } from "@/components/settings/ToggleButton";
@@ -2497,6 +2501,7 @@ function SettingsSidebar({
hostChromeInset?: boolean;
}) {
const { t } = useTranslation();
const activeNavItemRef = useRef<HTMLButtonElement>(null);
const activeItem = SETTINGS_NAV_ITEMS.find((item) => item.key === activeSection)
?? SETTINGS_NAV_ITEMS[0];
const ActiveIcon = activeItem.icon;
@@ -2569,19 +2574,26 @@ function SettingsSidebar({
</DropdownMenuContent>
</DropdownMenu>
<div className="hidden space-y-1 lg:block">
<SidebarSelectionHighlight
targetRef={activeNavItemRef}
activeId={activeSection}
scope="settings"
className="relative hidden space-y-1 lg:block"
>
{SETTINGS_NAV_ITEMS.map(({ key, icon: Icon, fallback }) => {
const active = key === activeSection;
return (
<button
ref={active ? activeNavItemRef : undefined}
key={key}
type="button"
aria-current={active ? "page" : undefined}
onClick={() => onSelectSection(key)}
className={cn(
"touch-target flex h-9 w-full items-center gap-2 rounded-[10px] px-2.5 text-left text-[13px] font-medium transition-colors",
"touch-target flex h-9 w-full items-center gap-2 rounded-xl px-2.5 text-left text-[13px] font-medium",
SIDEBAR_SELECTION_ITEM_CLASS,
active
? "bg-sidebar-accent text-foreground"
? "text-sidebar-accent-foreground"
: "text-muted-foreground/78 hover:bg-muted/45 hover:text-foreground",
)}
>
@@ -2592,7 +2604,7 @@ function SettingsSidebar({
</button>
);
})}
</div>
</SidebarSelectionHighlight>
</nav>
<div className="hidden lg:mt-auto lg:block lg:pt-4">
+44 -31
View File
@@ -202,6 +202,7 @@ interface ThreadComposerProps {
quotedContext?: string | null;
focusRequest?: number;
onQuotedContextChange?: (text: string | null) => void;
allowAttachments?: boolean;
}
const COMMAND_ICONS: Record<string, LucideIcon> = {
@@ -850,6 +851,7 @@ export function ThreadComposer({
quotedContext = null,
focusRequest = 0,
onQuotedContextChange,
allowAttachments = true,
}: ThreadComposerProps) {
const { t } = useTranslation();
const [value, setValue] = useState("");
@@ -913,6 +915,10 @@ export function ThreadComposer({
const { images, enqueue, remove, clear, restoreReadyImages, encoding, full } =
useAttachedImages({ ingressLimits });
useEffect(() => {
if (!allowAttachments) clear();
}, [allowAttachments, clear]);
const formatRejection = useCallback(
(reason: AttachmentError): string => {
const key = `thread.composer.imageRejected.${reason}`;
@@ -942,6 +948,7 @@ export function ThreadComposer({
const addFiles = useCallback(
(files: File[]) => {
if (!allowAttachments) return;
if (files.length === 0) return;
secondEnterPromptIdRef.current = null;
const { rejected } = enqueue(files);
@@ -951,7 +958,7 @@ export function ThreadComposer({
setInlineError(null);
}
},
[enqueue, formatRejection],
[allowAttachments, enqueue, formatRejection],
);
const {
@@ -1874,10 +1881,10 @@ export function ThreadComposer({
e.preventDefault();
submit();
}}
onDragEnter={onDragEnter}
onDragOver={onDragOver}
onDragLeave={onDragLeave}
onDrop={onDrop}
onDragEnter={allowAttachments ? onDragEnter : undefined}
onDragOver={allowAttachments ? onDragOver : undefined}
onDragLeave={allowAttachments ? onDragLeave : undefined}
onDrop={allowAttachments ? onDrop : undefined}
className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")}
>
{showSlashMenu ? (
@@ -1907,7 +1914,9 @@ export function ThreadComposer({
? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]"
: "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]",
disabled && "opacity-60",
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
allowAttachments
&& isDragging
&& "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
goalState?.active &&
"goal-shell-glow ring-1 ring-sky-400/35 motion-reduce:ring-sky-400/25 dark:ring-sky-400/45",
)}
@@ -2014,7 +2023,7 @@ export function ThreadComposer({
onKeyUp={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
onSelect={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
onPaste={onPaste}
onPaste={allowAttachments ? onPaste : undefined}
rows={1}
placeholder={resolvedPlaceholder}
disabled={disabled}
@@ -2057,30 +2066,34 @@ export function ThreadComposer({
isHero ? "gap-1.5" : "gap-2",
)}
>
<input
ref={fileInputRef}
type="file"
accept={ACCEPT_ATTR}
multiple
hidden
onChange={onFilePick}
/>
<Button
type="button"
size="icon"
variant="ghost"
disabled={attachButtonDisabled}
aria-label={t("thread.composer.attachImage")}
onClick={() => fileInputRef.current?.click()}
className={cn(
"thread-composer-action touch-target rounded-full text-muted-foreground hover:text-foreground",
isHero
? "h-8 w-8 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
: "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
)}
>
<Plus className={cn(isHero ? "h-[18px] w-[18px]" : "h-4 w-4")} />
</Button>
{allowAttachments ? (
<>
<input
ref={fileInputRef}
type="file"
accept={ACCEPT_ATTR}
multiple
hidden
onChange={onFilePick}
/>
<Button
type="button"
size="icon"
variant="ghost"
disabled={attachButtonDisabled}
aria-label={t("thread.composer.attachImage")}
onClick={() => fileInputRef.current?.click()}
className={cn(
"thread-composer-action touch-target rounded-full text-muted-foreground hover:text-foreground",
isHero
? "h-8 w-8 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card"
: "h-9 w-9 border border-border/55 bg-card shadow-[0_2px_8px_rgba(15,23,42,0.05)] hover:bg-card",
)}
>
<Plus className={cn(isHero ? "h-[18px] w-[18px]" : "h-4 w-4")} />
</Button>
</>
) : null}
{voiceRecorder.isRecording ? (
<VoiceRecordingMeter
ariaLabel={voiceRecordingStatusLabel}
@@ -16,6 +16,7 @@ interface ThreadHeaderProps {
minimal?: boolean;
promptNavigatorAction?: ReactNode;
sessionInfoAction?: ReactNode;
headerAction?: ReactNode;
}
export function ThreadHeader({
@@ -29,6 +30,7 @@ export function ThreadHeader({
minimal = false,
promptNavigatorAction,
sessionInfoAction,
headerAction,
}: ThreadHeaderProps) {
const { t } = useTranslation();
@@ -61,6 +63,7 @@ export function ThreadHeader({
</div>
<div className="ml-auto flex shrink-0 items-center gap-1">
{headerAction}
{sessionInfoAction}
{promptNavigatorAction}
{!hideThemeButton ? (
+82 -32
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { PointerEvent as ReactPointerEvent } from "react";
import type { PointerEvent as ReactPointerEvent, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { FilePreviewAvailabilityProvider } from "@/components/FilePreviewAvailabilityContext";
@@ -33,6 +33,7 @@ import {
} from "@/lib/mcp-preset-events";
import type { CanonicalRunSnapshot, StreamError } from "@/lib/nanobot-client";
import { inferProviderFromModelName, providerDisplayLabel } from "@/lib/provider-brand";
import { TEMPORARY_CHAT_ID_PREFIX } from "@/lib/quick-chat";
import type {
ChatSummary,
SettingsPayload,
@@ -315,6 +316,12 @@ interface ThreadShellProps {
settingsSnapshot?: SettingsPayload | null;
onOpenModelSettings?: () => void;
skills?: SkillSummary[];
allowConversationReset?: boolean;
showSessionInfo?: boolean;
emptyStateGreeting?: string;
emptyStateDescription?: string;
temporary?: boolean;
headerAction?: ReactNode;
}
function toModelBadgeLabel(modelName: string | null): string | null {
@@ -597,10 +604,16 @@ export function ThreadShell({
settingsSnapshot = null,
onOpenModelSettings,
skills = [],
allowConversationReset = true,
showSessionInfo = true,
emptyStateGreeting,
emptyStateDescription,
temporary = false,
headerAction,
}: ThreadShellProps) {
const { t } = useTranslation();
const chatId = session?.chatId ?? null;
const historyKey = session?.key ?? null;
const historyKey = temporary ? null : session?.key ?? null;
const {
messages: historical,
loading,
@@ -622,6 +635,16 @@ export function ThreadShell({
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
const [booting, setBooting] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const availableSlashCommands = useMemo(
() => temporary
? slashCommands.filter((command) =>
command.command === "/model" || command.command === "/stop",
)
: allowConversationReset
? slashCommands
: slashCommands.filter((command) => command.command !== "/new"),
[allowConversationReset, slashCommands, temporary],
);
const cliApps = useInstalledSettingItems({
getToken,
eventName: CLI_APPS_CHANGED_EVENT,
@@ -669,8 +692,9 @@ export function ThreadShell({
const initial = useMemo(() => {
if (!chatId) return historical;
if (temporary) return historical;
return messageCacheRef.current.get(chatId) ?? historical;
}, [chatId, historical]);
}, [chatId, historical, temporary]);
const handleTurnEnd = useCallback(() => {
if (chatId) activeViewportTurnByChatIdRef.current.delete(chatId);
setSubmittedViewportTurnId(null);
@@ -690,7 +714,13 @@ export function ThreadShell({
setMessages,
streamError,
dismissStreamError,
} = useNanobotStream(chatId, initial, hasPendingToolCalls, handleTurnEnd);
} = useNanobotStream(
chatId,
initial,
hasPendingToolCalls,
handleTurnEnd,
{ temporary },
);
useLayoutEffect(() => {
if (currentUiMessagesRef.current === messages) return;
@@ -819,9 +849,12 @@ export function ThreadShell({
const handleModelPresetChange = useCallback((name: string) => {
setLocalModelPreset(name);
if (chatId) {
void client.sendSystemCommand(chatId, `/model ${name}`).catch(() => {});
const request = temporary
? client.sendSystemCommand(chatId, `/model ${name}`, 5_000, { temporary: true })
: client.sendSystemCommand(chatId, `/model ${name}`);
void request.catch(() => {});
}
}, [chatId, client]);
}, [chatId, client, temporary]);
const modelPresetOptions = useMemo(
() => modelPresetOptionsFromSettings(settings),
[settings],
@@ -842,13 +875,16 @@ export function ThreadShell({
const withWorkspaceScope = useCallback(
(options?: SendOptions): SendOptions | undefined => {
if (temporary) {
return { ...(options ?? {}), temporary: true };
}
if (!workspaceScope) return options;
return {
...(options ?? {}),
workspaceScope,
};
},
[workspaceScope],
[temporary, workspaceScope],
);
const refreshModelSettings = useCallback(async () => {
@@ -882,11 +918,11 @@ export function ThreadShell({
return client.onChat(chatId, (event) => {
if (event.event !== "turn_model_updated") return;
setFallbackModelName(event.model_name);
});
}, [chatId, client]);
}, { temporary });
}, [chatId, client, temporary]);
useEffect(() => {
if (!chatId || loading) return;
if (!chatId || loading || temporary) return;
const cached = messageCacheRef.current.get(chatId);
const pendingCanonicalHydrate = pendingCanonicalHydrateRef.current.get(chatId);
const hasNewCanonicalHistory = (
@@ -1016,6 +1052,7 @@ export function ThreadShell({
historyLineage,
historyActiveTurnId,
hasPendingToolCalls,
temporary,
]);
useLayoutEffect(() => {
@@ -1067,7 +1104,7 @@ export function ThreadShell({
}, [chatId, hasPendingToolCalls, historyVersion, messages, reconcileTurnComplete]);
const refreshCanonicalHistory = useCallback(() => {
if (!chatId) return;
if (!chatId || temporary) return;
pendingCanonicalHydrateRef.current.set(chatId, {
historyLineage,
historyVersion,
@@ -1077,7 +1114,7 @@ export function ThreadShell({
uiRevision: uiRevisionRef.current,
});
refreshHistory();
}, [chatId, client, historyLineage, historyVersion, refreshHistory]);
}, [chatId, client, historyLineage, historyVersion, refreshHistory, temporary]);
useEffect(() => {
if (!chatId) return;
@@ -1144,16 +1181,22 @@ export function ThreadShell({
if (chatId) {
const prev = prevChatIdForCacheRef.current;
if (prev && prev !== chatId) {
messageCacheRef.current.set(prev, displayMessages);
if (prev.startsWith(TEMPORARY_CHAT_ID_PREFIX)) {
messageCacheRef.current.delete(prev);
} else {
messageCacheRef.current.set(prev, displayMessages);
}
skipLayoutCacheRef.current = true;
}
prevChatIdForCacheRef.current = chatId;
} else {
if (prevChatIdForCacheRef.current) {
messageCacheRef.current.set(
prevChatIdForCacheRef.current,
displayMessages,
);
const prev = prevChatIdForCacheRef.current;
if (prev.startsWith(TEMPORARY_CHAT_ID_PREFIX)) {
messageCacheRef.current.delete(prev);
} else {
messageCacheRef.current.set(prev, displayMessages);
}
skipLayoutCacheRef.current = true;
}
prevChatIdForCacheRef.current = null;
@@ -1164,7 +1207,7 @@ export function ThreadShell({
// ``useEffect`` reset has flushed; ``skipLayoutCacheRef`` drops the first run that still
// sees the *previous* chat's ``messages`` (avoids stale rows leaking across sessions).
useEffect(() => {
if (!chatId) {
if (!chatId || temporary) {
return;
}
if (skipLayoutCacheRef.current) {
@@ -1175,7 +1218,7 @@ export function ThreadShell({
return;
}
messageCacheRef.current.set(chatId, displayMessages);
}, [chatId, displayMessages, loading]);
}, [chatId, displayMessages, loading, temporary]);
// The landing composer queues the first message while `new_chat` is in flight.
// Only the chat created for that send may consume it; selecting another chat
@@ -1374,12 +1417,12 @@ export function ThreadShell({
fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant={showHeroComposer ? "hero" : "thread"}
slashCommands={slashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
skills={skills}
slashCommands={availableSlashCommands}
cliApps={temporary ? [] : cliApps}
mcpPresets={temporary ? [] : mcpPresets}
skills={temporary ? [] : skills}
onStop={stop}
onTranscribeAudio={transcribeAudio}
onTranscribeAudio={temporary ? undefined : transcribeAudio}
runStartedAt={currentRunStartedAt}
goalState={currentGoalState}
workspaceScope={workspaceScope}
@@ -1394,6 +1437,7 @@ export function ThreadShell({
quotedContext={quotedContext}
focusRequest={composerFocusSignal}
onQuotedContextChange={setQuotedContext}
allowAttachments={!temporary}
/>
) : (
<ThreadComposer
@@ -1416,7 +1460,7 @@ export function ThreadShell({
fallbackModelName={fallbackModelName}
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
variant="hero"
slashCommands={slashCommands}
slashCommands={availableSlashCommands}
cliApps={cliApps}
mcpPresets={mcpPresets}
skills={skills}
@@ -1442,10 +1486,15 @@ export function ThreadShell({
</div>
) : (
<div className="flex w-full flex-col items-center text-center animate-in fade-in-0 slide-in-from-bottom-2 duration-500">
<HeroGreeting text={t(heroGreetingKey)} />
<HeroGreeting text={emptyStateGreeting ?? t(heroGreetingKey)} />
{emptyStateDescription ? (
<p className="mt-3 max-w-xl text-sm text-muted-foreground">
{emptyStateDescription}
</p>
) : null}
</div>
);
const sessionInfoAction = historyKey ? (
const sessionInfoAction = historyKey && showSessionInfo ? (
<SessionInfoPopover sessionKey={historyKey} token={token} title={title} />
) : undefined;
const promptNavigatorAction = historyKey ? (
@@ -1470,6 +1519,7 @@ export function ThreadShell({
minimal={!session && !loading}
promptNavigatorAction={promptNavigatorAction}
sessionInfoAction={sessionInfoAction}
headerAction={headerAction}
/>
) : null}
<FilePreviewAvailabilityProvider
@@ -1486,17 +1536,17 @@ export function ThreadShell({
conversationKey={historyKey}
conversationReady={messagesReady}
showScrollToBottomButton={!!session}
cliApps={cliApps}
mcpPresets={mcpPresets}
slashCommands={slashCommands}
cliApps={temporary ? [] : cliApps}
mcpPresets={temporary ? [] : mcpPresets}
slashCommands={availableSlashCommands}
forkBoundaryMessageCount={forkBoundaryMessageCount}
hasMoreBefore={hasMoreBefore}
loadingOlder={loadingOlder}
userMessageOffset={userMessageOffset}
onLoadOlder={loadOlder}
onOpenFilePreview={historyKey ? handleOpenFilePreview : undefined}
onForkFromMessage={onForkChat ? handleForkFromMessage : undefined}
onQuoteSelection={session ? handleQuoteSelection : undefined}
onForkFromMessage={!temporary && onForkChat ? handleForkFromMessage : undefined}
onQuoteSelection={session && !temporary ? handleQuoteSelection : undefined}
/>
</FilePreviewAvailabilityProvider>
</div>
+18 -3
View File
@@ -487,6 +487,7 @@ export interface SendOptions {
finalizeActiveTurn?: boolean;
/** Append guidance to the running turn without detaching its active answer segment. */
continueActiveTurn?: boolean;
temporary?: boolean;
}
export interface SubmittedTurn {
@@ -546,6 +547,7 @@ export function useNanobotStream(
initialMessages: UIMessage[] = [],
hasPendingToolCalls = false,
onTurnEnd?: () => void,
options?: { temporary?: boolean },
): {
messages: UIMessage[];
/** Whether ``messages`` belongs to the current ``chatId`` after a session switch. */
@@ -1341,7 +1343,9 @@ export function useNanobotStream(
// ``attached`` frames aren't actionable here.
};
const unsub = client.onChat(chatId, handle);
const unsub = options?.temporary
? client.onChat(chatId, handle, { temporary: true })
: client.onChat(chatId, handle);
return () => {
unsub();
buffer.current = null;
@@ -1363,6 +1367,7 @@ export function useNanobotStream(
flushPendingStreamEvents,
isSideChannelEvent,
onTurnEnd,
options?.temporary,
schedulePendingStreamFlush,
scheduleStreamEndTimer,
]);
@@ -1450,8 +1455,18 @@ export function useNanobotStream(
return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
});
suppressStreamUntilTurnEndRef.current = false;
client.sendMessage(chatId, "/stop");
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
if (options?.temporary) {
client.sendMessage(chatId, "/stop", undefined, { temporary: true });
} else {
client.sendMessage(chatId, "/stop");
}
}, [
chatId,
clearActivitySegment,
client,
flushPendingStreamEvents,
options?.temporary,
]);
const reconcileTurnComplete = useCallback(() => {
cancelStreamEndTimer();
+12
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "Sidebar navigation",
"collapse": "Collapse sidebar",
"quickChat": "Quick Chat",
"newChat": "New topic",
"searchAria": "Search",
"searchPlaceholder": "Search",
@@ -60,6 +61,17 @@
"title": "Skills"
}
},
"quickChat": {
"greeting": "What's on your mind?",
"temporary": {
"title": "Temporary Chat",
"enter": "Temporary",
"active": "Temporary",
"exit": "Exit temporary chat",
"greeting": "Start a temporary chat",
"description": "No history, memory, tools, or project access. Content is still sent to your selected model provider."
}
},
"settings": {
"backToChat": "Back to chat",
"sidebar": {
+12
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "Navegación de la barra lateral",
"collapse": "Contraer barra lateral",
"quickChat": "Chat rápido",
"newChat": "Nuevo tema",
"searchAria": "Buscar",
"searchPlaceholder": "Buscar",
@@ -60,6 +61,17 @@
"title": "Habilidades"
}
},
"quickChat": {
"greeting": "¿Qué tienes en mente?",
"temporary": {
"title": "Chat temporal",
"enter": "Temporal",
"active": "Temporal",
"exit": "Salir del chat temporal",
"greeting": "Inicia un chat temporal",
"description": "Sin historial, memoria, herramientas ni acceso al proyecto. El contenido se envía al proveedor del modelo elegido."
}
},
"settings": {
"backToChat": "Volver al chat",
"sidebar": {
+12
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "Navigation de la barre latérale",
"collapse": "Réduire la barre latérale",
"quickChat": "Discussion rapide",
"newChat": "Nouveau sujet",
"searchAria": "Rechercher",
"searchPlaceholder": "Rechercher",
@@ -60,6 +61,17 @@
"title": "Compétences"
}
},
"quickChat": {
"greeting": "De quoi avez-vous envie de parler ?",
"temporary": {
"title": "Discussion temporaire",
"enter": "Temporaire",
"active": "Temporaire",
"exit": "Quitter la discussion temporaire",
"greeting": "Démarrer une discussion temporaire",
"description": "Aucun historique, mémoire, outil ou accès au projet. Le contenu est transmis au fournisseur du modèle choisi."
}
},
"settings": {
"backToChat": "Retour au chat",
"sidebar": {
+12
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "Navigasi bilah samping",
"collapse": "Ciutkan sidebar",
"quickChat": "Obrolan cepat",
"newChat": "Topik baru",
"searchAria": "Cari",
"searchPlaceholder": "Cari",
@@ -60,6 +61,17 @@
"title": "Skill"
}
},
"quickChat": {
"greeting": "Apa yang sedang kamu pikirkan?",
"temporary": {
"title": "Obrolan sementara",
"enter": "Sementara",
"active": "Sementara",
"exit": "Keluar dari obrolan sementara",
"greeting": "Mulai obrolan sementara",
"description": "Tanpa riwayat, memori, alat, atau akses proyek. Konten tetap dikirim ke penyedia model pilihan Anda."
}
},
"settings": {
"backToChat": "Kembali ke chat",
"sidebar": {
+12
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "サイドバーのナビゲーション",
"collapse": "サイドバーを閉じる",
"quickChat": "クイックチャット",
"newChat": "新しいトピック",
"searchAria": "検索",
"searchPlaceholder": "検索",
@@ -60,6 +61,17 @@
"title": "スキル"
}
},
"quickChat": {
"greeting": "何について話しますか?",
"temporary": {
"title": "一時チャット",
"enter": "一時チャット",
"active": "一時チャット中",
"exit": "一時チャットを終了",
"greeting": "一時チャットを始める",
"description": "履歴、メモリ、ツール、プロジェクトにはアクセスしません。内容は選択したモデル提供元に送信されます。"
}
},
"settings": {
"backToChat": "チャットに戻る",
"sidebar": {
+12
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "사이드바 탐색",
"collapse": "사이드바 접기",
"quickChat": "빠른 채팅",
"newChat": "새 주제",
"searchAria": "검색",
"searchPlaceholder": "검색",
@@ -60,6 +61,17 @@
"title": "스킬"
}
},
"quickChat": {
"greeting": "무슨 이야기를 나눠볼까요?",
"temporary": {
"title": "임시 채팅",
"enter": "임시 채팅",
"active": "임시 채팅 중",
"exit": "임시 채팅 종료",
"greeting": "임시 채팅 시작하기",
"description": "기록, 메모리, 도구, 프로젝트에 접근하지 않습니다. 내용은 선택한 모델 제공업체로 전송됩니다."
}
},
"settings": {
"backToChat": "채팅으로 돌아가기",
"sidebar": {
+12
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "Navegação da barra lateral",
"collapse": "Recolher barra lateral",
"quickChat": "Chat rápido",
"newChat": "Novo tópico",
"searchAria": "Buscar",
"searchPlaceholder": "Buscar",
@@ -60,6 +61,17 @@
"title": "Skills"
}
},
"quickChat": {
"greeting": "O que você está pensando?",
"temporary": {
"title": "Chat temporário",
"enter": "Temporário",
"active": "Temporário",
"exit": "Sair do chat temporário",
"greeting": "Inicie um chat temporário",
"description": "Sem histórico, memória, ferramentas ou acesso ao projeto. O conteúdo ainda é enviado ao provedor do modelo escolhido."
}
},
"settings": {
"backToChat": "Voltar para a conversa",
"sidebar": {
+12
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "Điều hướng thanh bên",
"collapse": "Thu gọn thanh bên",
"quickChat": "Trò chuyện nhanh",
"newChat": "Chủ đề mới",
"searchAria": "Tìm kiếm",
"searchPlaceholder": "Tìm kiếm",
@@ -60,6 +61,17 @@
"title": "Kỹ năng"
}
},
"quickChat": {
"greeting": "Bạn đang nghĩ gì?",
"temporary": {
"title": "Trò chuyện tạm thời",
"enter": "Tạm thời",
"active": "Tạm thời",
"exit": "Thoát trò chuyện tạm thời",
"greeting": "Bắt đầu trò chuyện tạm thời",
"description": "Không lịch sử, bộ nhớ, công cụ hay quyền truy cập dự án. Nội dung vẫn được gửi đến nhà cung cấp mô hình bạn chọn."
}
},
"settings": {
"backToChat": "Quay lại chat",
"sidebar": {
+12
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "侧边栏导航",
"collapse": "收起侧边栏",
"quickChat": "随便聊聊",
"newChat": "新建话题",
"searchAria": "搜索",
"searchPlaceholder": "搜索",
@@ -60,6 +61,17 @@
"title": "技能"
}
},
"quickChat": {
"greeting": "想聊点什么?",
"temporary": {
"title": "临时聊天",
"enter": "临时聊天",
"active": "临时聊天中",
"exit": "退出临时聊天",
"greeting": "开启一次临时聊天",
"description": "不保存记录,不读取记忆或项目,也不使用工具;内容仍会发送给你选择的模型服务商。"
}
},
"settings": {
"backToChat": "返回聊天",
"sidebar": {
+12
View File
@@ -43,6 +43,7 @@
"sidebar": {
"navigation": "側邊欄導覽",
"collapse": "收合側邊欄",
"quickChat": "輕鬆聊聊",
"newChat": "新增話題",
"searchAria": "搜尋",
"searchPlaceholder": "搜尋",
@@ -60,6 +61,17 @@
"title": "技能"
}
},
"quickChat": {
"greeting": "想聊點什麼?",
"temporary": {
"title": "臨時聊天",
"enter": "臨時聊天",
"active": "臨時聊天中",
"exit": "退出臨時聊天",
"greeting": "開啟一次臨時聊天",
"description": "不儲存記錄,不讀取記憶或專案,也不使用工具;內容仍會傳送給你選擇的模型服務商。"
}
},
"settings": {
"backToChat": "返回聊天",
"sidebar": {
+58 -6
View File
@@ -673,8 +673,12 @@ export class NanobotClient {
}
}
/** Subscribe to events for a given chat_id. Auto-attaches on the next open. */
onChat(chatId: string, handler: EventHandler): Unsubscribe {
/** Subscribe to events for a given chat_id. Auto-attaches unless it is temporary. */
onChat(
chatId: string,
handler: EventHandler,
options?: { temporary?: boolean },
): Unsubscribe {
let handlers = this.chatHandlers.get(chatId);
if (!handlers) {
handlers = new Set();
@@ -689,7 +693,7 @@ export class NanobotClient {
handler(ev);
}
}
this.attach(chatId);
if (!options?.temporary) this.attach(chatId);
return () => {
const current = this.chatHandlers.get(chatId);
if (!current) return;
@@ -809,9 +813,10 @@ export class NanobotClient {
turnId?: string;
/** False for side-channel or injected messages that do not own a lifecycle. */
startsNewRun?: boolean;
temporary?: boolean;
},
): void {
this.knownChats.add(chatId);
if (!options?.temporary) this.knownChats.add(chatId);
const frame: Outbound = {
type: "message",
chat_id: chatId,
@@ -822,6 +827,7 @@ export class NanobotClient {
...(options?.quotedContext?.trim() ? { quoted_context: options.quotedContext.trim() } : {}),
...(options?.workspaceScope ? { workspace_scope: options.workspaceScope } : {}),
...(options?.turnId ? { turn_id: options.turnId } : {}),
...(options?.temporary ? { temporary: true } : {}),
webui: true,
};
if (!this.frameFitsTransport(frame)) {
@@ -843,7 +849,12 @@ export class NanobotClient {
this.queueSend(frame);
}
sendSystemCommand(chatId: string, command: string, timeoutMs = 5_000): Promise<void> {
sendSystemCommand(
chatId: string,
command: string,
timeoutMs = 5_000,
options?: { temporary?: boolean },
): Promise<void> {
const normalized = command.trim();
const turnId = `${SYSTEM_COMMAND_TURN_PREFIX}${crypto.randomUUID()}`;
return new Promise<void>((resolve, reject) => {
@@ -852,10 +863,46 @@ export class NanobotClient {
reject(new Error("system command timed out"));
}, timeoutMs);
this.pendingSystemCommands.set(turnId, { resolve, reject, timer });
this.sendMessage(chatId, normalized, undefined, { turnId });
this.sendMessage(chatId, normalized, undefined, {
turnId,
temporary: options?.temporary,
});
});
}
discardTemporaryChat(chatId: string): void {
this.knownChats.delete(chatId);
this.chatHandlers.delete(chatId);
this.pendingInboundByChat.delete(chatId);
this.runStartedAtByChatId.delete(chatId);
this.goalStateByChatId.delete(chatId);
this.runGenerationByChatId.delete(chatId);
this.latestRunTurnIdByChatId.delete(chatId);
this.unsettledRunTurnIdsByChatId.delete(chatId);
this.canonicalCompletedTurnIdsByChatId.delete(chatId);
const turnKeyPrefix = `${chatId}\u0000`;
for (const key of this.runStartedAtByTurnKey.keys()) {
if (key.startsWith(turnKeyPrefix)) this.runStartedAtByTurnKey.delete(key);
}
for (const [key, pending] of this.pendingMessageSends) {
if (pending.chatId !== chatId) continue;
if (isSystemCommandTurnId(pending.turnId)) {
this.rejectSystemCommand(pending.turnId, "temporary chat discarded");
}
this.pendingMessageSends.delete(key);
this.socketPendingMessageSendKeys.delete(key);
}
if (this.lastSocketMessageSendKey?.startsWith(turnKeyPrefix)) {
this.lastSocketMessageSendKey = null;
}
this.sendQueue = this.sendQueue.filter(
(frame) => !("chat_id" in frame) || frame.chat_id !== chatId,
);
if (this.socket?.readyState === WS_OPEN) {
this.rawSend({ type: "discard_temporary_chat", chat_id: chatId });
}
}
setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void {
this.knownChats.add(chatId);
this.queueSend({
@@ -1007,6 +1054,11 @@ export class NanobotClient {
return;
}
if (parsed.event === "temporary_chat_discarded") {
this.pendingInboundByChat.delete(parsed.chat_id);
return;
}
if (parsed.event === "error" && parsed.detail === "workspace_scope_rejected") {
this.emitError({
kind: "workspace_scope_rejected",
+38
View File
@@ -0,0 +1,38 @@
import type { ChatSummary } from "@/lib/types";
export const QUICK_CHAT_ID = "quick-chat";
export const QUICK_CHAT_KEY = `websocket:${QUICK_CHAT_ID}`;
export const TEMPORARY_CHAT_ID_PREFIX = "temporary-";
export function isQuickChatKey(key: string | null): boolean {
return key === QUICK_CHAT_KEY;
}
export function quickChatSession(persisted?: ChatSummary): ChatSummary {
return {
key: QUICK_CHAT_KEY,
channel: "websocket",
chatId: QUICK_CHAT_ID,
createdAt: persisted?.createdAt ?? null,
updatedAt: persisted?.updatedAt ?? null,
preview: persisted?.preview ?? "",
modelPreset: persisted?.modelPreset ?? null,
runStartedAt: persisted?.runStartedAt ?? null,
workspaceScope: persisted?.workspaceScope ?? null,
};
}
export function createTemporaryChatSession(): ChatSummary {
const chatId = `${TEMPORARY_CHAT_ID_PREFIX}${crypto.randomUUID()}`;
return {
key: `websocket:${chatId}`,
channel: "websocket",
chatId,
createdAt: null,
updatedAt: null,
preview: "",
modelPreset: null,
runStartedAt: null,
workspaceScope: null,
};
}
+3
View File
@@ -1247,6 +1247,7 @@ export type InboundEvent =
scope?: "metadata" | "thread" | string;
workspace_scope?: WorkspaceScopePayload;
}
| { event: "temporary_chat_discarded"; chat_id: string }
| { event: "transcription_result"; request_id: string; text: string }
| {
event: "transcription_error";
@@ -1333,6 +1334,7 @@ export type Outbound =
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
| { type: "attach"; chat_id: string }
| { type: "discard_temporary_chat"; chat_id: string }
| { type: "set_workspace_scope"; chat_id: string; workspace_scope: WorkspaceScopePayload }
| { type: "transcribe_audio"; request_id: string; data_url: string; duration_ms?: number }
| {
@@ -1345,6 +1347,7 @@ export type Outbound =
quoted_context?: string;
workspace_scope?: WorkspaceScopePayload;
turn_id?: string;
temporary?: true;
/** Marks messages sent by the embedded WebUI, without changing the
* generic websocket protocol for other clients. */
webui?: true;
+178 -2
View File
@@ -12,6 +12,7 @@ const getSessionAutomationsSpy = vi.fn<(key: string) => Promise<SessionAutomatio
const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
const discardTemporaryChatSpy = vi.fn();
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
let mockSessions: ChatSummary[] = [];
@@ -219,6 +220,7 @@ vi.mock("@/lib/nanobot-client", () => {
sendMessage = vi.fn();
newChat = vi.fn();
attach = attachSpy;
discardTemporaryChat = discardTemporaryChatSpy;
close = vi.fn();
updateUrl = updateUrlSpy;
updateMaxFrameBytes = vi.fn();
@@ -246,6 +248,7 @@ describe("App layout", () => {
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
toggleThemeSpy.mockReset();
attachSpy.mockReset();
discardTemporaryChatSpy.mockReset();
runStatusHandlers.clear();
sessionUpdateHandlers.clear();
window.history.replaceState(null, "", "/");
@@ -349,6 +352,145 @@ describe("App layout", () => {
).toBeTruthy();
});
it("highlights the blank new-topic destination immediately", async () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const newTopicButton = within(sidebar).getByRole("button", { name: "New topic" });
expect(newTopicButton).toHaveAttribute("aria-current", "page");
expect(newTopicButton).not.toHaveClass("bg-sidebar-accent");
expect(newTopicButton).toHaveClass("transition-[width,padding,color]");
expect(within(sidebar).getByTestId("actions-selection-highlight")).toHaveAttribute(
"data-active-id",
"new-chat",
);
});
it("opens a single fixed Quick Chat without provisioning a new session", async () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
const quickChatButton = within(sidebar).getByRole("button", {
name: "Quick Chat",
});
const newTopicButton = within(sidebar).getByRole("button", {
name: "New topic",
});
const actionHighlight = within(sidebar).getByTestId(
"actions-selection-highlight",
);
fireEvent.click(quickChatButton);
expect(window.location.hash).toBe("#/quick-chat");
expect(quickChatButton).toHaveAttribute("aria-current", "page");
expect(newTopicButton).not.toHaveAttribute("aria-current");
expect(quickChatButton).not.toHaveClass("bg-sidebar-accent");
expect(quickChatButton).toHaveClass("transition-[width,padding,color]");
expect(actionHighlight).toHaveAttribute("data-active-id", "quick-chat");
expect(
within(sidebar).queryByTestId("actions-selection-highlight-surface"),
).not.toBeInTheDocument();
await waitFor(() =>
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining(
"/api/sessions/websocket%3Aquick-chat/webui-thread",
),
expect.anything(),
),
);
expect(createChatSpy).not.toHaveBeenCalled();
expect(document.title).toBe("Quick Chat · nanobot");
expect(screen.getByText("What's on your mind?")).toBeInTheDocument();
fireEvent.click(newTopicButton);
expect(window.location.hash).toBe("#/new");
expect(newTopicButton).toHaveAttribute("aria-current", "page");
expect(quickChatButton).not.toHaveAttribute("aria-current");
expect(actionHighlight).toHaveAttribute("data-active-id", "new-chat");
expect(within(sidebar).queryAllByRole("button", { current: "page" })).toHaveLength(1);
});
it("enters and destroys Temporary Chat inside Quick Chat", async () => {
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
fireEvent.click(within(sidebar).getByRole("button", { name: "Quick Chat" }));
fireEvent.click(screen.getByRole("button", { name: "Temporary" }));
expect(screen.getByText("Start a temporary chat")).toBeInTheDocument();
expect(screen.getByText(/No history, memory, tools, or project access/))
.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Attach image" })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Exit temporary chat" }));
expect(discardTemporaryChatSpy).toHaveBeenCalledTimes(1);
expect(discardTemporaryChatSpy.mock.calls[0][0]).toMatch(/^temporary-/);
await waitFor(() => {
expect(screen.getByText("What's on your mind?")).toBeInTheDocument();
});
});
it("restores Quick Chat before it has a persisted session", async () => {
window.history.replaceState(null, "", "/#/quick-chat");
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
expect(window.location.hash).toBe("#/quick-chat");
await waitFor(() =>
expect(fetch).toHaveBeenCalledWith(
expect.stringContaining(
"/api/sessions/websocket%3Aquick-chat/webui-thread",
),
expect.anything(),
),
);
expect(
within(screen.getByRole("navigation", { name: "Sidebar navigation" }))
.getByRole("button", { name: "Quick Chat" }),
).toHaveAttribute("aria-current", "page");
});
it("keeps persisted Quick Chat out of the topic list and topic search", async () => {
mockSessions = [
{
key: "websocket:quick-chat",
channel: "websocket",
chatId: "quick-chat",
createdAt: "2026-07-30T08:00:00Z",
updatedAt: "2026-07-30T08:05:00Z",
preview: "A private casual message",
},
{
key: "websocket:project-chat",
channel: "websocket",
chatId: "project-chat",
createdAt: "2026-07-30T08:00:00Z",
updatedAt: "2026-07-30T08:05:00Z",
preview: "Project roadmap",
},
];
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
expect(within(sidebar).getByText("Project roadmap")).toBeInTheDocument();
expect(within(sidebar).queryByText("A private casual message")).not.toBeInTheDocument();
fireEvent.click(within(sidebar).getByRole("button", { name: "Search" }));
const dialog = await screen.findByRole("dialog", { name: "Search" });
expect(within(dialog).getByText("Project roadmap")).toBeInTheDocument();
expect(within(dialog).queryByText("A private casual message")).not.toBeInTheDocument();
});
it("restores the Settings route after a restart fallback hash", async () => {
localStorage.setItem("nanobot-webui.restartStartedAt", String(Date.now()));
localStorage.setItem("nanobot-webui.restartRoute", "#/settings?section=channels");
@@ -2128,16 +2270,41 @@ describe("App layout", () => {
expect(window.location.hash).toBe("#/settings");
const settingsNav = screen.getByRole("navigation", { name: "Settings sections" });
fireEvent.click(within(settingsNav).getByRole("button", { name: "Models" }));
const overviewButton = within(settingsNav).getByRole("button", {
name: "Overview",
exact: true,
});
const modelsButton = within(settingsNav).getByRole("button", {
name: "Models",
exact: true,
});
const settingsHighlight = within(settingsNav).getByTestId(
"settings-selection-highlight",
);
expect(overviewButton).toHaveAttribute("aria-current", "page");
expect(overviewButton).not.toHaveClass("bg-sidebar-accent");
expect(overviewButton).toHaveClass("transition-[color]");
expect(settingsHighlight).toHaveAttribute("data-active-id", "overview");
fireEvent.click(modelsButton);
expect(await screen.findByText("Model presets")).toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Models" })).not.toBeInTheDocument();
expect(window.location.hash).toBe("#/settings?section=models");
expect(modelsButton).toHaveAttribute("aria-current", "page");
expect(settingsHighlight).toHaveAttribute("data-active-id", "models");
fireEvent.click(within(settingsNav).getByRole("button", { name: "Voice" }));
const voiceButton = within(settingsNav).getByRole("button", {
name: "Voice",
exact: true,
});
fireEvent.click(voiceButton);
expect(await screen.findByRole("heading", { name: "Voice input" })).toBeInTheDocument();
expect(window.location.hash).toBe("#/settings?section=voice");
expect(voiceButton).toHaveAttribute("aria-current", "page");
expect(settingsHighlight).toHaveAttribute("data-active-id", "voice");
});
it("transitions between Apps and Skills without replacing the sidebar", async () => {
@@ -2163,6 +2330,11 @@ describe("App layout", () => {
"aria-current",
"page",
);
expect(within(sidebar).getByTestId("actions-selection-highlight")).toHaveAttribute(
"data-active-id",
"utility:apps",
);
expect(within(sidebar).queryAllByRole("button", { current: "page" })).toHaveLength(1);
expect(screen.getByTestId("settings-section-transition")).toHaveAttribute(
"data-settings-section",
"apps",
@@ -2190,6 +2362,10 @@ describe("App layout", () => {
"aria-current",
"page",
);
expect(within(sidebar).getByTestId("actions-selection-highlight")).toHaveAttribute(
"data-active-id",
"utility:skills",
);
expect(document.title).toBe("Skills · nanobot");
});
+44 -20
View File
@@ -44,6 +44,7 @@ function rect({
describe("ChatList", () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("orders chats by latest session activity by default", () => {
@@ -220,8 +221,20 @@ describe("ChatList", () => {
expect(within(chatsSection).queryByText("Project chat")).not.toBeInTheDocument();
});
it("floats a borderless highlight in, then slides it between selected topics", () => {
it("positions one background highlight and resets it across hidden targets", () => {
let revealFrame: FrameRequestCallback | null = null;
let resizeObserverCallback: ResizeObserverCallback | null = null;
let activeTargetVisible = true;
class MockResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeObserverCallback = callback;
}
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal("ResizeObserver", MockResizeObserver);
vi.spyOn(window, "requestAnimationFrame").mockImplementation((callback) => {
revealFrame = callback;
return 1;
@@ -232,7 +245,9 @@ describe("ChatList", () => {
return rect({ left: 0, top: 0, width: 300, height: 200 });
}
if (this.getAttribute("data-chat-row") === "websocket:active") {
return rect({ left: 8, top: 12, width: 284, height: 32 });
return activeTargetVisible
? rect({ left: 8, top: 12, width: 284, height: 32 })
: rect({ left: 0, top: 0, width: 0, height: 0 });
}
if (this.getAttribute("data-chat-row") === "websocket:inactive") {
return rect({ left: 8, top: 48, width: 284, height: 40 });
@@ -253,30 +268,26 @@ describe("ChatList", () => {
};
const { rerender } = render(
<ChatList
{...props}
activeKey={null}
/>,
);
const highlight = screen.getByTestId("active-chat-highlight");
const surface = screen.getByTestId("active-chat-highlight-surface");
expect(surface).toHaveClass(
"bg-sidebar-foreground/[0.055]",
"transition-[opacity,transform]",
"motion-reduce:transition-none",
);
expect(surface).toHaveStyle("opacity: 0; transform: scale(0.97)");
rerender(
<ChatList
{...props}
activeKey="websocket:active"
/>,
);
const highlight = screen.getByTestId("sessions-selection-highlight");
expect(highlight).toHaveClass(
"bg-sidebar-foreground/[0.055]",
"transition-[transform,width,height]",
"motion-reduce:transition-none",
);
expect(screen.queryByTestId("sessions-selection-highlight-surface"))
.not.toBeInTheDocument();
expect(resizeObserverCallback).not.toBeNull();
const activeButton = screen.getByTitle("Active topic");
expect(activeButton).toHaveAttribute("aria-current", "page");
expect(activeButton.parentElement).toHaveClass("transition-[color]");
expect(activeButton.parentElement).not.toHaveClass("transition-colors");
expect(activeButton.parentElement).not.toHaveClass(
"bg-sidebar-accent",
"shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
@@ -286,13 +297,23 @@ describe("ChatList", () => {
"motion-reduce:transition-none",
);
expect(highlight).toHaveStyle(
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); transition-property: none",
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none",
);
expect(surface).toHaveStyle("opacity: 1; transform: scale(1)");
revealFrame?.(0);
expect(highlight.style.transitionProperty).toBe("");
activeTargetVisible = false;
resizeObserverCallback?.([], {} as ResizeObserver);
expect(highlight).toHaveStyle("opacity: 0");
activeTargetVisible = true;
resizeObserverCallback?.([], {} as ResizeObserver);
expect(highlight).toHaveStyle(
"width: 284px; height: 32px; transform: translate3d(8px, 12px, 0); opacity: 1; transition-property: none",
);
revealFrame?.(0);
rerender(
<ChatList
{...props}
@@ -305,6 +326,9 @@ describe("ChatList", () => {
expect(highlight).toHaveStyle(
"width: 284px; height: 40px; transform: translate3d(8px, 48px, 0)",
);
rerender(<ChatList {...props} activeKey={null} />);
expect(highlight).toHaveStyle("opacity: 0");
});
it("can collapse a project group and keeps project rename separate from chat titles", async () => {
+36
View File
@@ -70,6 +70,42 @@ afterEach(() => {
});
describe("NanobotClient", () => {
it("does not attach or retain temporary chats across reconnects", () => {
const client = new NanobotClient({
url: "ws://test",
reconnect: false,
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const handler = vi.fn();
client.onChat("temporary-one", handler, { temporary: true });
client.connect();
lastSocket().fakeOpen();
expect(lastSocket().sent).toEqual([]);
client.sendMessage("temporary-one", "hello", undefined, {
temporary: true,
turnId: "turn-temp",
});
expect(JSON.parse(lastSocket().sent.at(-1)!)).toMatchObject({
type: "message",
chat_id: "temporary-one",
temporary: true,
});
client.discardTemporaryChat("temporary-one");
expect(JSON.parse(lastSocket().sent.at(-1)!)).toEqual({
type: "discard_temporary_chat",
chat_id: "temporary-one",
});
lastSocket().fakeMessage({
event: "message",
chat_id: "temporary-one",
text: "late",
});
expect(handler).not.toHaveBeenCalled();
});
it("routes events to the matching chat handler", () => {
const client = new NanobotClient({
url: "ws://test",
+49
View File
@@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import {
createTemporaryChatSession,
isQuickChatKey,
QUICK_CHAT_ID,
QUICK_CHAT_KEY,
quickChatSession,
TEMPORARY_CHAT_ID_PREFIX,
} from "@/lib/quick-chat";
describe("Quick Chat identity", () => {
it("uses one stable websocket session", () => {
expect(QUICK_CHAT_ID).toBe("quick-chat");
expect(QUICK_CHAT_KEY).toBe("websocket:quick-chat");
expect(isQuickChatKey(QUICK_CHAT_KEY)).toBe(true);
expect(isQuickChatKey("websocket:another-chat")).toBe(false);
});
it("keeps persisted metadata behind the fixed identity", () => {
expect(quickChatSession({
key: "websocket:quick-chat",
channel: "websocket",
chatId: "quick-chat",
createdAt: "2026-07-30T08:00:00Z",
updatedAt: "2026-07-30T08:05:00Z",
preview: "hello",
modelPreset: "fast",
})).toMatchObject({
key: QUICK_CHAT_KEY,
channel: "websocket",
chatId: QUICK_CHAT_ID,
createdAt: "2026-07-30T08:00:00Z",
updatedAt: "2026-07-30T08:05:00Z",
preview: "hello",
modelPreset: "fast",
});
});
it("creates isolated temporary identities without replacing Quick Chat", () => {
const first = createTemporaryChatSession();
const second = createTemporaryChatSession();
expect(first.chatId).toMatch(new RegExp(`^${TEMPORARY_CHAT_ID_PREFIX}`));
expect(first.key).toBe(`websocket:${first.chatId}`);
expect(first.key).not.toBe(second.key);
expect(isQuickChatKey(first.key)).toBe(false);
});
});
+13
View File
@@ -2576,4 +2576,17 @@ describe("ThreadComposer", () => {
});
});
it("removes every attachment entry point when attachments are disabled", () => {
render(
<ThreadComposer
onSend={vi.fn()}
allowAttachments={false}
placeholder="Type your message..."
/>,
);
expect(screen.queryByRole("button", { name: "Attach image" })).not.toBeInTheDocument();
expect(document.querySelector('input[type="file"]')).toBeNull();
});
});
+111 -11
View File
@@ -86,6 +86,22 @@ function makeClient() {
runStartedAtByChatId.delete(chatId);
return true;
});
const onChat = vi.fn((
chatId: string,
handler: (ev: import("@/lib/types").InboundEvent) => void,
options?: { temporary?: boolean },
) => {
void options;
let handlers = chatHandlers.get(chatId);
if (!handlers) {
handlers = new Set();
chatHandlers.set(chatId, handlers);
}
handlers.add(handler);
return () => {
handlers?.delete(handler);
};
});
return {
get status() {
return status;
@@ -112,17 +128,7 @@ function makeClient() {
canReconcileCanonicalCompletion,
reconcileCanonicalCompletion,
getGoalState: (chatId: string) => goalStateByChatId.get(chatId),
onChat: (chatId: string, handler: (ev: import("@/lib/types").InboundEvent) => void) => {
let handlers = chatHandlers.get(chatId);
if (!handlers) {
handlers = new Set();
chatHandlers.set(chatId, handlers);
}
handlers.add(handler);
return () => {
handlers?.delete(handler);
};
},
onChat,
onError: (handler: (err: StreamError) => void) => {
errorHandlers.add(handler);
return () => {
@@ -3369,6 +3375,100 @@ describe("ThreadShell", () => {
expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument();
});
it("marks every temporary chat subscription as temporary", async () => {
const client = makeClient();
render(
wrap(
client,
<ThreadShell
session={session("temporary-test")}
title="Temporary Chat"
onToggleSidebar={() => {}}
temporary
/>,
),
);
await waitFor(() => {
const temporaryCalls = client.onChat.mock.calls.filter(
([chatId]) => chatId === "temporary-test",
);
expect(temporaryCalls.length).toBeGreaterThanOrEqual(2);
expect(temporaryCalls.every(([, , options]) => (
options?.temporary === true
))).toBe(true);
});
});
it("removes session-management affordances from a fixed conversation", async () => {
const client = makeClient();
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url.endsWith("/api/commands")) {
return httpJson({
commands: [
{
command: "/new",
title: "New chat",
description: "Reset this chat and start a fresh conversation.",
icon: "square-pen",
lifecycle: "finalize_active_turn",
accepts_args: false,
},
{
command: "/history",
title: "Show conversation history",
description: "Print the last N persisted messages.",
icon: "history",
arg_hint: "[n]",
lifecycle: "side_channel",
accepts_args: true,
},
],
});
}
return {
ok: false,
status: 404,
json: async () => ({}),
};
}),
);
render(
wrap(
client,
<ThreadShell
session={session("quick-chat")}
title="Quick Chat"
onToggleSidebar={() => {}}
allowConversationReset={false}
showSessionInfo={false}
/>,
),
);
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
"/api/commands",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
));
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "/" },
});
expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument();
expect(screen.queryByRole("option", { name: /\/new/i })).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Session details" }),
).not.toBeInTheDocument();
});
it("does not bring back welcome cards when image mode is enabled", async () => {
const client = makeClient();
const settings = modelSettings("deepseek-v4-pro", "deepseek");