feat(webui): add quick and temporary chats

This commit is contained in:
Xubin Ren 2026-07-31 01:59:50 +08:00
parent db6c9effc3
commit 15d7e7c822
44 changed files with 1689 additions and 171 deletions

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

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,

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:

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)

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,

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,

View File

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

View File

@ -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:

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,

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

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)

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,

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

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)
# ---------------------------------------------------------------------------

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()

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

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

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()

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={}),

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

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,10 +2012,12 @@ function Shell({
}, [activeSession, headerTitle, i18n.resolvedLanguage, t, view]);
const sidebarProps = {
sessions,
sessions: regularSessions,
activeKey: view === "chat" ? activeKey : null,
loading,
quickChatActive: view === "chat" && quickChatActive,
newChatActive: view === "chat" && activeKey === null,
onOpenQuickChat,
onNewChat,
onSelect: onSelectChat,
onRequestDelete,
@ -2066,7 +2180,7 @@ function Shell({
<SessionSearchDialog
open
onOpenChange={setSessionSearchOpen}
sessions={sessions}
sessions={regularSessions}
activeKey={activeKey}
loading={loading}
titleOverrides={sidebarState.title_overrides}
@ -2091,7 +2205,7 @@ function Shell({
onToggleSidebar={toggleSidebar}
onNewChat={onNewChat}
onCreateChat={onCreateChat}
onForkChat={onForkChat}
onForkChat={quickChatActive ? undefined : onForkChat}
onTurnEnd={onTurnEnd}
theme={theme}
onToggleTheme={toggle}
@ -2099,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" && (

View File

@ -8,6 +8,7 @@ import {
Archive,
Brain,
CalendarClock,
MessageCircle,
Menu,
Search,
Settings,
@ -33,7 +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;
@ -93,11 +96,13 @@ export function Sidebar(props: SidebarProps) {
const toggleLabel = t("thread.header.toggleSidebar");
const newChatShortcut = newChatShortcutLabel();
const activeActionRef = useRef<HTMLButtonElement>(null);
const activeActionId = props.newChatActive
? "new-chat"
: props.activeUtility
? `utility:${props.activeUtility}`
: null;
const activeActionId = props.quickChatActive
? "quick-chat"
: props.newChatActive
? "new-chat"
: props.activeUtility
? `utility:${props.activeUtility}`
: null;
return (
<nav
@ -158,6 +163,14 @@ export function Sidebar(props: SidebarProps) {
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")}

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}

View File

@ -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 ? (

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>

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();

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": {

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": {

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": {

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": {

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": {

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": {

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": {

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": {

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": {

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": {

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",

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,
};
}

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;

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, "", "/");
@ -365,6 +368,129 @@ describe("App layout", () => {
);
});
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");

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",

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);
});
});

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();
});
});

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");