mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 13:28:43 +03:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
325f7b0367 | ||
|
|
4d77189d9c | ||
|
|
68591cfd4b | ||
|
|
f971d7e895 | ||
|
|
36253685bd | ||
|
|
324a61dff1 |
@@ -13,7 +13,11 @@ from nanobot.agent.tools import mcp as mcp_tools
|
||||
from nanobot.agent.tools import sessions as session_tools
|
||||
from nanobot.agent.tools.registry import ToolRegistry
|
||||
from nanobot.apps.cli import utils as cli_app_utils
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.events import (
|
||||
INBOUND_META_RUNTIME_CONTROL,
|
||||
RUNTIME_CONTROL_SESSION_DISCARD,
|
||||
InboundMessage,
|
||||
)
|
||||
from nanobot.runtime_context import (
|
||||
RUNTIME_CONTEXT_END,
|
||||
RUNTIME_CONTEXT_MESSAGE_META,
|
||||
@@ -47,6 +51,9 @@ async def close_mcp(state: Any) -> None:
|
||||
|
||||
|
||||
async def handle_runtime_control(state: Any, msg: InboundMessage, tools: ToolRegistry) -> bool:
|
||||
if msg.metadata.get(INBOUND_META_RUNTIME_CONTROL) == RUNTIME_CONTROL_SESSION_DISCARD:
|
||||
await state.discard_session(msg.session_key)
|
||||
return True
|
||||
for handler in (
|
||||
image_generation_tools.handle_runtime_control,
|
||||
mcp_tools.handle_runtime_control,
|
||||
@@ -79,6 +86,7 @@ class ContextBuilder:
|
||||
channel: str | None = None,
|
||||
session_summary: str | None = None,
|
||||
workspace: Path | None = None,
|
||||
include_memory: bool = True,
|
||||
include_memory_recent_history: bool = True,
|
||||
session_key: str | None = None,
|
||||
unified_session: bool = False,
|
||||
@@ -93,9 +101,10 @@ class ContextBuilder:
|
||||
|
||||
parts.append(render_template("agent/tool_contract.md"))
|
||||
|
||||
memory = self.memory.read_memory()
|
||||
if memory and not self._is_template_content(memory, "memory/MEMORY.md"):
|
||||
parts.append(f"# Memory\n\n## Long-term Memory\n{memory}")
|
||||
if include_memory:
|
||||
memory = self.memory.read_memory()
|
||||
if memory and not self._is_template_content(memory, "memory/MEMORY.md"):
|
||||
parts.append(f"# Memory\n\n## Long-term Memory\n{memory}")
|
||||
|
||||
active_skills = self.skills.get_always_skills()
|
||||
active_skills.extend(
|
||||
@@ -219,6 +228,7 @@ class ContextBuilder:
|
||||
session_summary: str | None = None,
|
||||
runtime_context_blocks: Sequence[RuntimeContextBlock] | None = None,
|
||||
workspace: Path | None = None,
|
||||
include_memory: bool = True,
|
||||
include_memory_recent_history: bool = True,
|
||||
session_key: str | None = None,
|
||||
unified_session: bool = False,
|
||||
@@ -238,6 +248,7 @@ class ContextBuilder:
|
||||
channel=channel,
|
||||
session_summary=session_summary,
|
||||
workspace=root,
|
||||
include_memory=include_memory,
|
||||
include_memory_recent_history=include_memory_recent_history,
|
||||
session_key=session_key,
|
||||
unified_session=unified_session,
|
||||
|
||||
+48
-9
@@ -398,6 +398,7 @@ class AgentLoop:
|
||||
self._mcp_connecting = False
|
||||
self._runtime_context_providers: list[RuntimeContextProvider] = []
|
||||
self._active_tasks: dict[str, set[asyncio.Task[Any]]] = {}
|
||||
self._discarding_sessions: set[str] = set()
|
||||
self._background_tasks: set[asyncio.Task[Any]] = set()
|
||||
self._close_mcp_lock = asyncio.Lock()
|
||||
self._session_locks: weakref.WeakValueDictionary[str, asyncio.Lock] = (
|
||||
@@ -721,6 +722,7 @@ class AgentLoop:
|
||||
session_summary=ctx.pending_summary,
|
||||
workspace=scope.project_path,
|
||||
runtime_context_blocks=ctx.runtime_context_blocks,
|
||||
include_memory=ctx.session.policy.persist,
|
||||
include_memory_recent_history=not ctx.ephemeral,
|
||||
session_key=ctx.session.key,
|
||||
unified_session=self._unified_session,
|
||||
@@ -798,6 +800,15 @@ class AgentLoop:
|
||||
sub_cancelled = await self.subagents.cancel_by_session(key)
|
||||
return cancelled + sub_cancelled
|
||||
|
||||
async def discard_session(self, key: str) -> None:
|
||||
"""Stop active work for *key* and forget its cached session."""
|
||||
self._discarding_sessions.add(key)
|
||||
try:
|
||||
self.sessions.invalidate(key)
|
||||
await self._cancel_active_tasks(key)
|
||||
finally:
|
||||
self._discarding_sessions.discard(key)
|
||||
|
||||
def _effective_session_key(self, msg: InboundMessage) -> str:
|
||||
"""Return the session key used for task routing and mid-turn injections."""
|
||||
if self._unified_session and not msg.session_key_override:
|
||||
@@ -1161,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.require_existing_session
|
||||
and self.sessions.get_cached(effective_key) is None
|
||||
):
|
||||
continue
|
||||
if self.commands.is_priority(raw):
|
||||
await self._dispatch_command_inline(
|
||||
msg, effective_key, raw,
|
||||
@@ -1279,6 +1295,8 @@ class AgentLoop:
|
||||
# _emit_checkpoint during tool execution; materializing
|
||||
# it into session history now makes it visible in the
|
||||
# next conversation turn.
|
||||
if session_key in self._discarding_sessions:
|
||||
raise
|
||||
try:
|
||||
key = self._effective_session_key(msg)
|
||||
session = self.sessions.get_or_create(key)
|
||||
@@ -1556,6 +1574,7 @@ class AgentLoop:
|
||||
had_injections: bool,
|
||||
streamed_content: bool,
|
||||
*,
|
||||
log_content: bool = True,
|
||||
turn_latency_ms: int | None = None,
|
||||
) -> OutboundMessage | None:
|
||||
"""Assemble the final outbound message from turn results."""
|
||||
@@ -1564,8 +1583,11 @@ class AgentLoop:
|
||||
if not had_injections or stop_reason == "empty_final_response":
|
||||
return None
|
||||
|
||||
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
||||
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
|
||||
if log_content:
|
||||
preview = final_content[:120] + "..." if len(final_content) > 120 else final_content
|
||||
logger.info("Response to {}:{}: {}", msg.channel, msg.sender_id, preview)
|
||||
else:
|
||||
logger.info("Response to {}:{}: [content hidden]", msg.channel, msg.sender_id)
|
||||
|
||||
event = None
|
||||
meta = dict(msg.metadata or {})
|
||||
@@ -1594,17 +1616,33 @@ class AgentLoop:
|
||||
ctx.msg = dataclasses.replace(msg, content=new_content, media=image_paths)
|
||||
msg = ctx.msg
|
||||
|
||||
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
|
||||
if ctx.session is None:
|
||||
if msg.require_existing_session:
|
||||
ctx.session = self.sessions.get_cached(ctx.session_key)
|
||||
if ctx.session is None:
|
||||
raise RuntimeError("required session is not active")
|
||||
else:
|
||||
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
||||
session = ctx.session
|
||||
ctx.ephemeral = ctx.ephemeral or not session.policy.persist
|
||||
tools = ctx.tools or self.tools
|
||||
if session.policy.disabled_tools:
|
||||
restricted = ToolRegistry()
|
||||
for name in tools.tool_names:
|
||||
tool = tools.get(name)
|
||||
if name not in session.policy.disabled_tools and tool:
|
||||
restricted.register(tool)
|
||||
tools = restricted
|
||||
ctx.tools = tools
|
||||
|
||||
if ctx.kind is TurnKind.SYSTEM:
|
||||
logger.info("Processing system message from {}", msg.sender_id)
|
||||
else:
|
||||
elif session.policy.log_content:
|
||||
preview = msg.content[:80] + "..." if len(msg.content) > 80 else msg.content
|
||||
logger.info("Processing message from {}:{}: {}", msg.channel, msg.sender_id, preview)
|
||||
else:
|
||||
logger.info("Processing message from {}:{}: [content hidden]", msg.channel, msg.sender_id)
|
||||
|
||||
# Session is already fetched by the caller (_process_message) but
|
||||
# ensure it exists in case this handler is invoked independently.
|
||||
if ctx.session is None:
|
||||
ctx.session = self.sessions.get_or_create(ctx.session_key)
|
||||
session = ctx.session
|
||||
self._remember_unified_session_route(
|
||||
session,
|
||||
msg,
|
||||
@@ -1907,6 +1945,7 @@ class AgentLoop:
|
||||
ctx.stop_reason,
|
||||
ctx.had_injections,
|
||||
ctx.streamed_content,
|
||||
log_content=ctx.require_session().policy.log_content,
|
||||
turn_latency_ms=ctx.turn_latency_ms,
|
||||
)
|
||||
if ctx.ephemeral and ctx.outbound is not None:
|
||||
|
||||
@@ -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"
|
||||
RUNTIME_CONTROL_SESSION_DISCARD = "session_discard"
|
||||
|
||||
|
||||
@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
|
||||
require_existing_session: bool = False
|
||||
|
||||
@property
|
||||
def session_key(self) -> str:
|
||||
|
||||
@@ -254,6 +254,7 @@ class BaseChannel(ABC):
|
||||
session_key: str | None = None,
|
||||
is_dm: bool = False,
|
||||
authorization_id: str | None = None,
|
||||
require_existing_session: bool = False,
|
||||
) -> None:
|
||||
"""Handle a message after checking its authorization subject.
|
||||
|
||||
@@ -306,6 +307,7 @@ class BaseChannel(ABC):
|
||||
media=media or [],
|
||||
metadata=meta,
|
||||
session_key_override=session_key,
|
||||
require_existing_session=require_existing_session,
|
||||
)
|
||||
|
||||
await self.bus.publish_inbound(msg)
|
||||
|
||||
@@ -431,6 +431,7 @@ class SignalChannel(BaseChannel):
|
||||
session_key: str | None = None,
|
||||
is_dm: bool = False,
|
||||
authorization_id: str | None = None,
|
||||
require_existing_session: bool = False,
|
||||
) -> None:
|
||||
"""Handle an inbound message whose policy has already been checked.
|
||||
|
||||
@@ -453,6 +454,7 @@ class SignalChannel(BaseChannel):
|
||||
media=media or [],
|
||||
metadata=meta,
|
||||
session_key_override=session_key,
|
||||
require_existing_session=require_existing_session,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ 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 (
|
||||
OUTBOUND_META_AGENT_UI,
|
||||
OutboundMessage,
|
||||
)
|
||||
from nanobot.bus.outbound_events import (
|
||||
GoalStateSyncEvent,
|
||||
GoalStatusEvent,
|
||||
@@ -49,6 +52,7 @@ from nanobot.security.workspace_access import (
|
||||
from nanobot.session.goal_state import goal_state_ws_blob
|
||||
from nanobot.session.webui_turns import (
|
||||
clear_websocket_turn_if_current,
|
||||
clear_websocket_turns,
|
||||
mark_websocket_turn_transcript_persistence_failed,
|
||||
register_queued_websocket_turn_if_idle,
|
||||
websocket_turn_id,
|
||||
@@ -82,6 +86,7 @@ from nanobot.webui.session_access import (
|
||||
session_mentions_runtime_context,
|
||||
)
|
||||
from nanobot.webui.sidebar_state import write_webui_sidebar_state
|
||||
from nanobot.webui.temporary_chats import TemporaryChatError
|
||||
from nanobot.webui.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY
|
||||
from nanobot.webui.transcription_ws import webui_transcription_event
|
||||
from nanobot.webui.websocket_logging import websockets_server_logger
|
||||
@@ -394,6 +399,7 @@ class WebSocketChannel(BaseChannel):
|
||||
self._ingress = gateway.ingress
|
||||
self._transcripts = gateway.transcripts
|
||||
self._workspaces = gateway.workspaces
|
||||
self._temporary_chats = gateway.temporary_chats
|
||||
self._session_access = (
|
||||
WebuiSessionAccess(gateway.session_manager)
|
||||
if gateway.session_manager is not None
|
||||
@@ -412,6 +418,33 @@ 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 _discard_connection_owned_chat(
|
||||
self,
|
||||
connection: ServerConnection,
|
||||
chat_id: str,
|
||||
) -> None:
|
||||
await self._temporary_chats.discard(connection, chat_id)
|
||||
self._detach(connection, chat_id)
|
||||
clear_websocket_turns(chat_id)
|
||||
self._clear_stream_buffers(chat_id)
|
||||
|
||||
async def send_webui_protocol_error(
|
||||
self,
|
||||
connection: ServerConnection,
|
||||
@@ -440,16 +473,16 @@ 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())
|
||||
chat_ids = tuple(self._conn_chats.get(connection, ()))
|
||||
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)
|
||||
if self._temporary_chats.owns(connection, cid):
|
||||
await self._discard_connection_owned_chat(connection, cid)
|
||||
else:
|
||||
self._detach(connection, cid)
|
||||
for cid in self._temporary_chats.chat_ids_for_owner(connection):
|
||||
await self._discard_connection_owned_chat(connection, cid)
|
||||
self._conn_default.pop(connection, None)
|
||||
self._webui_connections.discard(connection)
|
||||
|
||||
@@ -502,7 +535,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)
|
||||
|
||||
@@ -729,7 +762,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 ---------------------------------------
|
||||
|
||||
@@ -764,14 +797,46 @@ class WebSocketChannel(BaseChannel):
|
||||
)
|
||||
await self._hydrate_after_subscribe(new_id)
|
||||
return
|
||||
if t == "new_temporary_chat":
|
||||
try:
|
||||
new_id = self._temporary_chats.create(
|
||||
connection,
|
||||
trusted_webui=connection in self._webui_connections,
|
||||
)
|
||||
except TemporaryChatError as exc:
|
||||
await self._send_event(connection, "error", detail=exc.detail)
|
||||
return
|
||||
self._attach(connection, new_id)
|
||||
await self._send_event(
|
||||
connection,
|
||||
"attached",
|
||||
chat_id=new_id,
|
||||
temporary=True,
|
||||
)
|
||||
return
|
||||
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_valid_chat_id(cid):
|
||||
await self._send_event(connection, "error", detail="invalid temporary chat_id")
|
||||
return
|
||||
try:
|
||||
await self._discard_connection_owned_chat(connection, cid)
|
||||
except TemporaryChatError as exc:
|
||||
await self._send_event(connection, "error", detail=exc.detail, 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
|
||||
try:
|
||||
self._temporary_chats.validate_attach(cid)
|
||||
except TemporaryChatError as exc:
|
||||
await self._send_event(connection, "error", detail=exc.detail, chat_id=cid)
|
||||
return
|
||||
self._attach(connection, cid)
|
||||
await self._send_event(connection, "attached", chat_id=cid)
|
||||
await self._hydrate_after_subscribe(cid)
|
||||
@@ -805,6 +870,11 @@ class WebSocketChannel(BaseChannel):
|
||||
if not _is_valid_chat_id(cid):
|
||||
await self._send_event(connection, "error", detail="invalid chat_id")
|
||||
return
|
||||
try:
|
||||
self._temporary_chats.validate_workspace_update(cid)
|
||||
except TemporaryChatError as exc:
|
||||
await self._send_event(connection, "error", detail=exc.detail, chat_id=cid)
|
||||
return
|
||||
scope = await self._workspace_scope_or_error(
|
||||
connection,
|
||||
lambda: self._workspaces.scope_for_set_request(
|
||||
@@ -873,6 +943,21 @@ class WebSocketChannel(BaseChannel):
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
temporary_policy = self._temporary_chats.message_policy(
|
||||
connection,
|
||||
cid,
|
||||
content,
|
||||
)
|
||||
except TemporaryChatError as exc:
|
||||
await self._send_event(
|
||||
connection,
|
||||
"error",
|
||||
detail=exc.detail,
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
|
||||
raw_media = envelope.get("media")
|
||||
media_paths: list[str] = []
|
||||
if raw_media is not None:
|
||||
@@ -895,6 +980,8 @@ class WebSocketChannel(BaseChannel):
|
||||
**rejection_fields,
|
||||
)
|
||||
return
|
||||
if temporary_policy is not None:
|
||||
self._temporary_chats.register_media(connection, cid, media_paths)
|
||||
|
||||
# Allow media-only turns (content may be empty when attachments are present).
|
||||
if not content.strip() and not media_paths:
|
||||
@@ -907,16 +994,21 @@ class WebSocketChannel(BaseChannel):
|
||||
return
|
||||
# Auto-attach on first use so clients can one-shot without a separate attach.
|
||||
self._attach(connection, cid)
|
||||
await self._hydrate_after_subscribe(cid)
|
||||
if temporary_policy is None or temporary_policy.hydrate_transcript:
|
||||
await self._hydrate_after_subscribe(cid)
|
||||
|
||||
# Resolve after hydration so a concurrent downgrade cannot be overwritten.
|
||||
scope = await self._workspace_scope_or_error(
|
||||
connection,
|
||||
lambda: self._workspaces.scope_for_message(
|
||||
envelope,
|
||||
chat_id=cid,
|
||||
chat_running=websocket_turn_wall_started_at(cid) is not None,
|
||||
controls_available=self._workspace_controls_available(connection),
|
||||
lambda: (
|
||||
temporary_policy.workspace_scope
|
||||
if temporary_policy is not None
|
||||
else self._workspaces.scope_for_message(
|
||||
envelope,
|
||||
chat_id=cid,
|
||||
chat_running=websocket_turn_wall_started_at(cid) is not None,
|
||||
controls_available=self._workspace_controls_available(connection),
|
||||
)
|
||||
),
|
||||
chat_id=cid,
|
||||
turn_id=turn_id,
|
||||
@@ -969,7 +1061,13 @@ class WebSocketChannel(BaseChannel):
|
||||
metadata[WEBSOCKET_TURN_OWNER_METADATA_KEY] = queued_owner
|
||||
accepted = False
|
||||
try:
|
||||
if is_webui:
|
||||
if (
|
||||
is_webui
|
||||
and (
|
||||
temporary_policy is None
|
||||
or temporary_policy.persist_transcript
|
||||
)
|
||||
):
|
||||
self._transcripts.append_user_message(
|
||||
cid,
|
||||
content,
|
||||
@@ -998,6 +1096,16 @@ class WebSocketChannel(BaseChannel):
|
||||
media=media_paths or None,
|
||||
metadata=metadata,
|
||||
is_dm=False,
|
||||
session_key=(
|
||||
temporary_policy.session_key
|
||||
if temporary_policy is not None
|
||||
else None
|
||||
),
|
||||
require_existing_session=(
|
||||
temporary_policy.require_existing_session
|
||||
if temporary_policy is not None
|
||||
else False
|
||||
),
|
||||
)
|
||||
accepted = True
|
||||
finally:
|
||||
@@ -1058,6 +1166,7 @@ class WebSocketChannel(BaseChannel):
|
||||
self._conn_default.clear()
|
||||
self._webui_connections.clear()
|
||||
self._tokens.clear()
|
||||
self._temporary_chats.close()
|
||||
|
||||
async def _safe_send_to(
|
||||
self,
|
||||
@@ -1070,7 +1179,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)
|
||||
@@ -1087,6 +1196,8 @@ class WebSocketChannel(BaseChannel):
|
||||
transcript_overrides: dict[str, Any] | None = None,
|
||||
) -> bool:
|
||||
"""Persist one canonical turn event and retain unsafe owners on failure."""
|
||||
if not self._temporary_chats.should_persist_transcript(chat_id):
|
||||
return True
|
||||
persisted = self._transcripts.prepare_and_append(
|
||||
chat_id,
|
||||
event,
|
||||
|
||||
@@ -13,7 +13,9 @@ from websockets.exceptions import ConnectionClosed
|
||||
from websockets.frames import Close
|
||||
|
||||
from nanobot.bus.events import (
|
||||
INBOUND_META_RUNTIME_CONTROL,
|
||||
OUTBOUND_META_AGENT_UI,
|
||||
RUNTIME_CONTROL_SESSION_DISCARD,
|
||||
OutboundMessage,
|
||||
)
|
||||
from nanobot.bus.outbound_events import (
|
||||
@@ -37,6 +39,7 @@ from nanobot.channels.websocket.runtime import (
|
||||
from nanobot.config.loader import load_config, save_config
|
||||
from nanobot.config.schema import Config, ModelPresetConfig
|
||||
from nanobot.runtime_context import RUNTIME_CONTEXT_INPUT_META, WEBUI_QUOTE_SOURCE
|
||||
from nanobot.security.workspace_access import WORKSPACE_SCOPE_METADATA_KEY
|
||||
from nanobot.session import webui_turns as wth
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.webui.gateway_services import GatewayServices, build_gateway_services
|
||||
@@ -193,6 +196,302 @@ def isolate_webui_workspace_state(tmp_path, monkeypatch) -> None:
|
||||
wth._WEBSOCKET_TURN_OWNERS.clear()
|
||||
|
||||
|
||||
async def _new_temporary_chat(
|
||||
channel: WebSocketChannel,
|
||||
connection: AsyncMock,
|
||||
) -> str:
|
||||
channel._webui_connections.add(connection)
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"webui-client",
|
||||
{"type": "new_temporary_chat"},
|
||||
)
|
||||
payload = json.loads(connection.send.await_args.args[0])
|
||||
assert payload["event"] == "attached"
|
||||
assert payload["temporary"] is True
|
||||
connection.send.reset_mock()
|
||||
return payload["chat_id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_chat_is_transient_and_discarded(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
selected_project = tmp_path / "selected-project"
|
||||
selected_project.mkdir()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(
|
||||
bus,
|
||||
session_manager=sessions,
|
||||
workspace_path=tmp_path,
|
||||
),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
connection.remote_address = ("127.0.0.1", 5000)
|
||||
chat_id = await _new_temporary_chat(channel, connection)
|
||||
upload = tmp_path / "temporary-upload.txt"
|
||||
upload.write_text("private attachment", encoding="utf-8")
|
||||
channel.gateway.media.store_inbound_attachments = MagicMock(
|
||||
return_value=([str(upload)], None),
|
||||
)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": chat_id,
|
||||
"content": "read this",
|
||||
"media": [{"data_url": "data:text/plain;base64,cHJpdmF0ZQ=="}],
|
||||
"cli_apps": [{"name": "drawio"}],
|
||||
"workspace_scope": {
|
||||
"project_path": str(selected_project),
|
||||
"access_mode": "full",
|
||||
},
|
||||
"turn_id": "turn-1",
|
||||
"webui": True,
|
||||
},
|
||||
)
|
||||
|
||||
inbound = bus.publish_inbound.await_args_list[0].args[0]
|
||||
assert inbound.session_key == f"websocket:{chat_id}"
|
||||
assert inbound.session_key_override == f"websocket:{chat_id}"
|
||||
assert inbound.require_existing_session is True
|
||||
assert inbound.metadata["cli_apps"] == [{"name": "drawio"}]
|
||||
assert inbound.metadata[WORKSPACE_SCOPE_METADATA_KEY] == {
|
||||
"project_path": str(tmp_path.resolve()),
|
||||
"access_mode": "restricted",
|
||||
}
|
||||
session = sessions.get_cached(inbound.session_key)
|
||||
assert session is not None
|
||||
assert session.policy.persist is False
|
||||
assert upload.exists()
|
||||
assert read_transcript_lines(inbound.session_key) == []
|
||||
assert [payload["event"] for payload in _sent_ws_payloads(connection)] == [
|
||||
"message_accepted",
|
||||
]
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"webui-client",
|
||||
{"type": "discard_temporary_chat", "chat_id": chat_id},
|
||||
)
|
||||
|
||||
control = bus.publish_inbound.await_args_list[1].args[0]
|
||||
assert bus.publish_inbound.await_count == 2
|
||||
assert control.session_key == inbound.session_key
|
||||
assert control.metadata[INBOUND_META_RUNTIME_CONTROL] == (
|
||||
RUNTIME_CONTROL_SESSION_DISCARD
|
||||
)
|
||||
assert sessions.get_cached(inbound.session_key) is None
|
||||
assert chat_id not in channel._subs
|
||||
assert chat_id not in channel._conn_chats.get(connection, set())
|
||||
assert not upload.exists()
|
||||
assert read_transcript_lines(inbound.session_key) == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("content", ["/goal private", "/trigger later", "/dream"])
|
||||
async def test_temporary_chat_rejects_persistent_commands(bus, tmp_path, content) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
connection.remote_address = ("127.0.0.1", 5000)
|
||||
chat_id = await _new_temporary_chat(channel, connection)
|
||||
|
||||
await channel._dispatch_envelope(connection, "webui-client", {
|
||||
"type": "message",
|
||||
"chat_id": chat_id,
|
||||
"content": content,
|
||||
"webui": True,
|
||||
})
|
||||
|
||||
assert bus.publish_inbound.await_count == 0
|
||||
assert sessions.get_cached(f"websocket:{chat_id}") is not None
|
||||
assert json.loads(connection.send.await_args.args[0])["detail"] == (
|
||||
"temporary_chat_command_rejected"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_discards_temporary_chat(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(
|
||||
bus,
|
||||
session_manager=sessions,
|
||||
workspace_path=tmp_path,
|
||||
),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
chat_id = await _new_temporary_chat(channel, connection)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": chat_id,
|
||||
"content": "hello",
|
||||
"webui": True,
|
||||
},
|
||||
)
|
||||
await channel._cleanup_connection(connection)
|
||||
|
||||
session_key = f"websocket:{chat_id}"
|
||||
control = bus.publish_inbound.await_args_list[-1].args[0]
|
||||
assert control.session_key == session_key
|
||||
assert control.metadata[INBOUND_META_RUNTIME_CONTROL] == (
|
||||
RUNTIME_CONTROL_SESSION_DISCARD
|
||||
)
|
||||
assert sessions.get_cached(session_key) is None
|
||||
assert chat_id not in channel._subs
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_chat_creation_requires_authenticated_webui_connection(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"generic-websocket-client",
|
||||
{"type": "new_temporary_chat"},
|
||||
)
|
||||
|
||||
assert json.loads(connection.send.await_args.args[0])["detail"] == "access_denied"
|
||||
assert sessions.list_sessions() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_chat_cannot_be_claimed_by_another_connection(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
owner = AsyncMock()
|
||||
other = AsyncMock()
|
||||
channel._webui_connections.add(other)
|
||||
chat_id = await _new_temporary_chat(channel, owner)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
other,
|
||||
"other-webui-client",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": chat_id,
|
||||
"content": "claim it",
|
||||
"webui": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert json.loads(other.send.await_args.args[0])["detail"] == (
|
||||
"temporary_chat_unavailable"
|
||||
)
|
||||
assert bus.publish_inbound.await_count == 0
|
||||
assert sessions.get_cached(f"websocket:{chat_id}") is not None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_chat_cannot_persist_workspace_scope(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
chat_id = await _new_temporary_chat(channel, connection)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "set_workspace_scope",
|
||||
"chat_id": chat_id,
|
||||
"workspace_scope": {
|
||||
"project_path": str(tmp_path),
|
||||
"access_mode": "full",
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
payload = json.loads(connection.send.await_args.args[0])
|
||||
assert payload["detail"] == "temporary_chat_workspace_rejected"
|
||||
session = sessions.get_cached(f"websocket:{chat_id}")
|
||||
assert session is not None
|
||||
assert WORKSPACE_SCOPE_METADATA_KEY not in session.metadata
|
||||
assert sessions.list_sessions() == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_temporary_looking_id_does_not_define_session_policy(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
channel._webui_connections.add(connection)
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"webui-client",
|
||||
{
|
||||
"type": "message",
|
||||
"chat_id": "temporary-looking-but-persistent",
|
||||
"content": "/goal ordinary chat",
|
||||
"webui": True,
|
||||
},
|
||||
)
|
||||
|
||||
inbound = bus.publish_inbound.await_args.args[0]
|
||||
assert inbound.require_existing_session is False
|
||||
assert inbound.session_key_override is None
|
||||
session = sessions.get_cached("websocket:temporary-looking-but-persistent")
|
||||
assert session is not None
|
||||
assert session.policy.persist is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discard_temporary_chat_does_not_detach_persistent_chat(bus, tmp_path) -> None:
|
||||
sessions = SessionManager(tmp_path)
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=tmp_path),
|
||||
)
|
||||
connection = AsyncMock()
|
||||
channel._attach(connection, "ordinary-chat")
|
||||
|
||||
await channel._dispatch_envelope(
|
||||
connection,
|
||||
"webui-client",
|
||||
{"type": "discard_temporary_chat", "chat_id": "ordinary-chat"},
|
||||
)
|
||||
|
||||
assert json.loads(connection.send.await_args.args[0])["detail"] == (
|
||||
"temporary_chat_unavailable"
|
||||
)
|
||||
assert connection in channel._subs["ordinary-chat"]
|
||||
assert "ordinary-chat" in channel._conn_chats[connection]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_session_updated_broadcasts_to_other_webui_connections(bus) -> None:
|
||||
class Conn:
|
||||
|
||||
@@ -11,7 +11,7 @@ from copy import deepcopy
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Protocol, TypedDict, cast
|
||||
from typing import Any, Callable, Collection, Protocol, TypedDict, cast
|
||||
from weakref import WeakValueDictionary
|
||||
|
||||
from loguru import logger
|
||||
@@ -147,6 +147,15 @@ class RetentionResult:
|
||||
already_consolidated_count: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SessionPolicy:
|
||||
"""Runtime rules that do not belong in durable session data."""
|
||||
|
||||
persist: bool = True
|
||||
log_content: bool = True
|
||||
disabled_tools: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
@dataclass
|
||||
class Session:
|
||||
"""A conversation session."""
|
||||
@@ -158,6 +167,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)
|
||||
policy: SessionPolicy = field(default_factory=SessionPolicy, repr=False, compare=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not isinstance(cast(object, self.metadata), dict):
|
||||
@@ -1079,6 +1089,24 @@ class SessionManager:
|
||||
self._remember(session)
|
||||
return session
|
||||
|
||||
def get_or_create_transient(
|
||||
self,
|
||||
key: str,
|
||||
*,
|
||||
disabled_tools: Collection[str] = (),
|
||||
) -> Session:
|
||||
"""Return a fresh, non-persistent session without loading history."""
|
||||
policy = SessionPolicy(
|
||||
persist=False,
|
||||
log_content=False,
|
||||
disabled_tools=frozenset(disabled_tools),
|
||||
)
|
||||
session = self.get_cached(key)
|
||||
if session is None or session.policy != policy:
|
||||
session = Session(key=key, policy=policy)
|
||||
self._remember(session)
|
||||
return session
|
||||
|
||||
def _load(self, key: str) -> Session | None:
|
||||
return self._store.load(key)
|
||||
|
||||
@@ -1092,6 +1120,9 @@ class SessionManager:
|
||||
|
||||
def save(self, session: Session, *, fsync: bool = False) -> None:
|
||||
"""Persist a session and retain it in the cache."""
|
||||
if not session.policy.persist:
|
||||
return
|
||||
|
||||
archiver = self._file_cap_archiver
|
||||
if archiver is not None:
|
||||
session.enforce_file_cap(
|
||||
|
||||
@@ -334,6 +334,12 @@ def clear_websocket_turn_if_current(
|
||||
return False
|
||||
|
||||
|
||||
def clear_websocket_turns(chat_id: str) -> None:
|
||||
"""Forget every in-process turn projection for a discarded chat."""
|
||||
_WEBSOCKET_ACTIVE_TURNS.pop(chat_id, None)
|
||||
_sync_websocket_turn_projection(chat_id)
|
||||
|
||||
|
||||
def build_bus_progress_callback(
|
||||
bus: MessageBus,
|
||||
msg: InboundMessage,
|
||||
|
||||
@@ -11,6 +11,7 @@ from loguru import logger as default_logger
|
||||
from nanobot.webui.gateway_tokens import GatewayTokenStore
|
||||
from nanobot.webui.ingress_policy import DEFAULT_WEBUI_INGRESS_POLICY, WebUIIngressPolicy
|
||||
from nanobot.webui.media_gateway import WebUIMediaGateway
|
||||
from nanobot.webui.temporary_chats import WebUITemporaryChats
|
||||
from nanobot.webui.transcript import WebUITranscriptRecorder
|
||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||
from nanobot.webui.ws_http import GatewayHTTPHandler
|
||||
@@ -33,6 +34,7 @@ class GatewayServices:
|
||||
ingress: WebUIIngressPolicy
|
||||
transcripts: WebUITranscriptRecorder
|
||||
workspaces: WebUIWorkspaceController
|
||||
temporary_chats: WebUITemporaryChats
|
||||
session_manager: SessionManager | None
|
||||
cron_service: CronService | None
|
||||
local_trigger_store: LocalTriggerStore | None
|
||||
@@ -82,6 +84,12 @@ def build_gateway_services(
|
||||
default_workspace=workspace_path,
|
||||
default_restrict_to_workspace=default_restrict_to_workspace,
|
||||
)
|
||||
temporary_chats = WebUITemporaryChats(
|
||||
bus=bus,
|
||||
session_manager=session_manager,
|
||||
workspaces=workspaces,
|
||||
logger=logger,
|
||||
)
|
||||
http = GatewayHTTPHandler(
|
||||
config=config,
|
||||
session_manager=session_manager,
|
||||
@@ -112,6 +120,7 @@ def build_gateway_services(
|
||||
ingress=ingress,
|
||||
transcripts=transcripts,
|
||||
workspaces=workspaces,
|
||||
temporary_chats=temporary_chats,
|
||||
session_manager=session_manager,
|
||||
cron_service=cron_service,
|
||||
local_trigger_store=local_trigger_store,
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""Connection-owned Temporary Chat behavior for the WebUI."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from nanobot.bus.events import (
|
||||
INBOUND_META_RUNTIME_CONTROL,
|
||||
RUNTIME_CONTROL_SESSION_DISCARD,
|
||||
InboundMessage,
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.security.workspace_access import WorkspaceScope
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.webui.workspaces import WebUIWorkspaceController
|
||||
|
||||
_TEMPORARY_CHAT_DISABLED_TOOLS = frozenset({
|
||||
"create_goal",
|
||||
"update_goal",
|
||||
"spawn",
|
||||
"cron",
|
||||
})
|
||||
_TEMPORARY_CHAT_COMMANDS = frozenset({"/model", "/stop"})
|
||||
|
||||
|
||||
class TemporaryChatError(ValueError):
|
||||
"""A stable WebUI protocol error for a Temporary Chat operation."""
|
||||
|
||||
def __init__(self, detail: str) -> None:
|
||||
super().__init__(detail)
|
||||
self.detail = detail
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TemporaryChatMessagePolicy:
|
||||
"""Server-owned message rules for one active Temporary Chat."""
|
||||
|
||||
session_key: str
|
||||
workspace_scope: WorkspaceScope
|
||||
require_existing_session: bool = True
|
||||
hydrate_transcript: bool = False
|
||||
persist_transcript: bool = False
|
||||
|
||||
|
||||
class WebUITemporaryChats:
|
||||
"""Own Temporary Chat creation, policy, attachments, and disposal."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
bus: MessageBus,
|
||||
session_manager: SessionManager | None,
|
||||
workspaces: WebUIWorkspaceController,
|
||||
logger: Any,
|
||||
channel_name: str = "websocket",
|
||||
) -> None:
|
||||
self._bus = bus
|
||||
self._sessions = session_manager
|
||||
self._workspaces = workspaces
|
||||
self._logger = logger
|
||||
self._channel_name = channel_name
|
||||
self._owners: dict[str, object] = {}
|
||||
self._owner_chat_ids: dict[object, set[str]] = {}
|
||||
# Keep active sessions alive if the bounded manager cache evicts them
|
||||
# between WebUI turns. SessionPolicy remains the authority below.
|
||||
self._active_sessions: dict[str, Session] = {}
|
||||
# Retain policy-derived tombstones until shutdown so late outbound
|
||||
# events cannot create a durable transcript after a chat is discarded.
|
||||
self._known_transient_chat_ids: set[str] = set()
|
||||
self._media_paths: dict[str, set[str]] = {}
|
||||
|
||||
def _session_key(self, chat_id: str) -> str:
|
||||
return f"{self._channel_name}:{chat_id}"
|
||||
|
||||
def _cached_session_is_transient(self, chat_id: str) -> bool:
|
||||
if self._sessions is None:
|
||||
return False
|
||||
session = self._sessions.get_cached(self._session_key(chat_id))
|
||||
return session is not None and not session.policy.persist
|
||||
|
||||
def create(self, owner: object, *, trusted_webui: bool) -> str:
|
||||
"""Create a server-identified chat owned by one authenticated WebUI connection."""
|
||||
if not trusted_webui:
|
||||
raise TemporaryChatError("access_denied")
|
||||
if self._sessions is None:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
|
||||
chat_id = str(uuid.uuid4())
|
||||
session = self._sessions.get_or_create_transient(
|
||||
self._session_key(chat_id),
|
||||
disabled_tools=_TEMPORARY_CHAT_DISABLED_TOOLS,
|
||||
)
|
||||
if session.policy.persist:
|
||||
raise RuntimeError("Temporary Chat must use a non-persistent session policy")
|
||||
self._owners[chat_id] = owner
|
||||
self._owner_chat_ids.setdefault(owner, set()).add(chat_id)
|
||||
self._active_sessions[chat_id] = session
|
||||
self._known_transient_chat_ids.add(chat_id)
|
||||
return chat_id
|
||||
|
||||
def message_policy(
|
||||
self,
|
||||
owner: object,
|
||||
chat_id: str,
|
||||
content: str,
|
||||
) -> TemporaryChatMessagePolicy | None:
|
||||
"""Return Temporary Chat rules, or ``None`` for an ordinary chat."""
|
||||
if not self._cached_session_is_transient(chat_id):
|
||||
if chat_id in self._known_transient_chat_ids:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
return None
|
||||
if self._owners.get(chat_id) is not owner or self._sessions is None:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
|
||||
session = self._sessions.get_cached(self._session_key(chat_id))
|
||||
if session is None:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
|
||||
command = content.strip().split(maxsplit=1)[0].lower() if content.strip() else ""
|
||||
if command.startswith("/") and command not in _TEMPORARY_CHAT_COMMANDS:
|
||||
raise TemporaryChatError("temporary_chat_command_rejected")
|
||||
|
||||
return TemporaryChatMessagePolicy(
|
||||
session_key=self._session_key(chat_id),
|
||||
workspace_scope=self._workspaces.restricted_default_scope(),
|
||||
)
|
||||
|
||||
def validate_attach(self, chat_id: str) -> None:
|
||||
"""Reject attempts to recover a non-persistent session."""
|
||||
if not self._cached_session_is_transient(chat_id):
|
||||
if chat_id in self._known_transient_chat_ids:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
return
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
|
||||
def validate_workspace_update(self, chat_id: str) -> None:
|
||||
"""Prevent non-persistent sessions from acquiring durable workspace state."""
|
||||
if self._cached_session_is_transient(chat_id):
|
||||
raise TemporaryChatError("temporary_chat_workspace_rejected")
|
||||
if chat_id in self._known_transient_chat_ids:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
|
||||
def register_media(self, owner: object, chat_id: str, paths: list[str]) -> None:
|
||||
if not paths:
|
||||
return
|
||||
if self._owners.get(chat_id) is not owner:
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
self._media_paths.setdefault(chat_id, set()).update(paths)
|
||||
|
||||
def chat_ids_for_owner(self, owner: object) -> tuple[str, ...]:
|
||||
return tuple(self._owner_chat_ids.get(owner, ()))
|
||||
|
||||
def owns(self, owner: object, chat_id: str) -> bool:
|
||||
return self._owners.get(chat_id) is owner
|
||||
|
||||
def should_persist_transcript(self, chat_id: str) -> bool:
|
||||
"""Apply the session policy and retain it for late events after disposal."""
|
||||
return (
|
||||
not self._cached_session_is_transient(chat_id)
|
||||
and chat_id not in self._known_transient_chat_ids
|
||||
)
|
||||
|
||||
def _discard_media(self, chat_id: str) -> None:
|
||||
for raw_path in self._media_paths.pop(chat_id, set()):
|
||||
try:
|
||||
Path(raw_path).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
self._logger.warning("failed to remove a temporary WebUI attachment")
|
||||
|
||||
def _forget_owner(self, owner: object, chat_id: str) -> None:
|
||||
self._owners.pop(chat_id, None)
|
||||
chat_ids = self._owner_chat_ids.get(owner)
|
||||
if chat_ids is None:
|
||||
return
|
||||
chat_ids.discard(chat_id)
|
||||
if not chat_ids:
|
||||
self._owner_chat_ids.pop(owner, None)
|
||||
|
||||
async def discard(self, owner: object, chat_id: str) -> None:
|
||||
"""Forget one owned chat and cancel any active work through the message bus."""
|
||||
if (
|
||||
not self._cached_session_is_transient(chat_id)
|
||||
or self._owners.get(chat_id) is not owner
|
||||
):
|
||||
raise TemporaryChatError("temporary_chat_unavailable")
|
||||
|
||||
session_key = self._session_key(chat_id)
|
||||
self._forget_owner(owner, chat_id)
|
||||
self._active_sessions.pop(chat_id, None)
|
||||
self._discard_media(chat_id)
|
||||
if self._sessions is not None:
|
||||
self._sessions.invalidate(session_key)
|
||||
await self._bus.publish_inbound(
|
||||
InboundMessage(
|
||||
channel=self._channel_name,
|
||||
sender_id="webui",
|
||||
chat_id=chat_id,
|
||||
content="",
|
||||
metadata={
|
||||
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_SESSION_DISCARD,
|
||||
},
|
||||
session_key_override=session_key,
|
||||
)
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release process-local resources during gateway shutdown."""
|
||||
for chat_id in tuple(self._owners):
|
||||
self._discard_media(chat_id)
|
||||
if self._sessions is not None:
|
||||
self._sessions.invalidate(self._session_key(chat_id))
|
||||
self._owners.clear()
|
||||
self._owner_chat_ids.clear()
|
||||
self._active_sessions.clear()
|
||||
self._known_transient_chat_ids.clear()
|
||||
@@ -191,6 +191,14 @@ class WebUIWorkspaceController:
|
||||
self._default_restrict_to_workspace,
|
||||
)
|
||||
|
||||
def restricted_default_scope(self) -> WorkspaceScope:
|
||||
"""Return the default workspace with access restricted for this request."""
|
||||
return build_workspace_scope(
|
||||
self._default_workspace,
|
||||
"restricted",
|
||||
source_channel=_WEBUI_SCOPE_CHANNEL,
|
||||
)
|
||||
|
||||
def _scope_from_metadata_value(
|
||||
self,
|
||||
raw_scope: object,
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import (
|
||||
INBOUND_META_RUNTIME_CONTROL,
|
||||
RUNTIME_CONTROL_SESSION_DISCARD,
|
||||
InboundMessage,
|
||||
)
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.providers.base import GenerationSettings, LLMResponse
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY
|
||||
|
||||
|
||||
def _message(key: str, content: str) -> InboundMessage:
|
||||
return InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="user",
|
||||
chat_id=key.removeprefix("websocket:"),
|
||||
content=content,
|
||||
session_key_override=key,
|
||||
require_existing_session=True,
|
||||
)
|
||||
|
||||
|
||||
def _loop(tmp_path, responses: list[str], **kwargs) -> AgentLoop:
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
provider.generation = GenerationSettings()
|
||||
provider.chat_with_retry = AsyncMock(
|
||||
side_effect=[LLMResponse(content=response, usage={}) for response in responses]
|
||||
)
|
||||
return AgentLoop(
|
||||
bus=MessageBus(),
|
||||
provider=provider,
|
||||
workspace=tmp_path,
|
||||
model="test-model",
|
||||
cron_service=MagicMock(),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transient_session_keeps_history_without_persisting_or_durable_tools(tmp_path) -> None:
|
||||
loop = _loop(tmp_path, ["first answer", "second answer"])
|
||||
loop.context.memory.write_memory("private durable memory")
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock()
|
||||
key = "websocket:transient-test"
|
||||
loop.sessions.get_or_create_transient(
|
||||
key,
|
||||
disabled_tools={"create_goal", "update_goal", "spawn", "cron"},
|
||||
)
|
||||
|
||||
await loop._process_message(_message(key, "first question"))
|
||||
await loop._process_message(_message(key, "second question"))
|
||||
|
||||
calls = loop.provider.chat_with_retry.await_args_list
|
||||
assert "private durable memory" not in str(calls[0].kwargs["messages"])
|
||||
tool_names = {item["function"]["name"] for item in calls[0].kwargs["tools"]}
|
||||
assert "read_session" in tool_names
|
||||
assert {"create_goal", "update_goal", "spawn", "cron"}.isdisjoint(tool_names)
|
||||
assert "first answer" in str(calls[1].kwargs["messages"])
|
||||
session = loop.sessions.get_cached(key)
|
||||
assert session is not None
|
||||
assert [message["role"] for message in session.messages] == [
|
||||
"user",
|
||||
"assistant",
|
||||
"user",
|
||||
"assistant",
|
||||
]
|
||||
assert loop.sessions.read_session_file(key) is None
|
||||
loop.consolidator.maybe_consolidate_by_tokens.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transient_session_stays_outside_unified_session(tmp_path) -> None:
|
||||
loop = _loop(tmp_path, ["private answer"], unified_session=True)
|
||||
durable = loop.sessions.get_or_create(UNIFIED_SESSION_KEY)
|
||||
durable.add_message("user", "durable question")
|
||||
loop.sessions.save(durable)
|
||||
key = "websocket:transient-unified"
|
||||
transient = loop.sessions.get_or_create_transient(key)
|
||||
|
||||
await loop._dispatch(_message(key, "private question"))
|
||||
|
||||
assert [message["content"] for message in transient.messages] == [
|
||||
"private question",
|
||||
"private answer",
|
||||
]
|
||||
assert [message["content"] for message in durable.messages] == ["durable question"]
|
||||
assert loop.sessions.read_session_file(key) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_missing_required_session_cannot_fall_back_to_disk(tmp_path) -> None:
|
||||
loop = _loop(tmp_path, [])
|
||||
key = "websocket:transient-stale"
|
||||
loop.sessions.get_or_create_transient(key)
|
||||
loop.sessions.invalidate(key)
|
||||
|
||||
with pytest.raises(RuntimeError, match="required session is not active"):
|
||||
await loop._process_message(_message(key, "stale private message"))
|
||||
|
||||
loop.provider.chat_with_retry.assert_not_awaited()
|
||||
assert loop.sessions.read_session_file(key) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_discard_control_cancels_active_turn(tmp_path, monkeypatch) -> None:
|
||||
provider_started = asyncio.Event()
|
||||
|
||||
async def block_provider(**_kwargs: object) -> LLMResponse:
|
||||
provider_started.set()
|
||||
await asyncio.Event().wait()
|
||||
raise AssertionError("provider blocker unexpectedly released")
|
||||
|
||||
loop = _loop(tmp_path, [])
|
||||
|
||||
async def wait_for_discard(key: str) -> None:
|
||||
while loop.sessions.get_cached(key) is not None:
|
||||
await asyncio.sleep(0)
|
||||
|
||||
loop.provider.chat_with_retry = AsyncMock(side_effect=block_provider)
|
||||
monkeypatch.setattr(loop, "_connect_mcp", AsyncMock())
|
||||
monkeypatch.setattr(loop, "close_mcp", AsyncMock())
|
||||
key = "websocket:transient-cancelled"
|
||||
loop.sessions.get_or_create_transient(
|
||||
key,
|
||||
disabled_tools={"create_goal", "update_goal", "spawn", "cron"},
|
||||
)
|
||||
run_task = asyncio.create_task(loop.run())
|
||||
await loop.bus.publish_inbound(_message(key, "private"))
|
||||
await asyncio.wait_for(provider_started.wait(), timeout=2)
|
||||
active_task = next(iter(loop._active_tasks[key]))
|
||||
|
||||
await loop.bus.publish_inbound(
|
||||
InboundMessage(
|
||||
channel="websocket",
|
||||
sender_id="webui",
|
||||
chat_id="transient-cancelled",
|
||||
content="",
|
||||
metadata={
|
||||
INBOUND_META_RUNTIME_CONTROL: RUNTIME_CONTROL_SESSION_DISCARD,
|
||||
},
|
||||
session_key_override=key,
|
||||
)
|
||||
)
|
||||
|
||||
with pytest.raises(asyncio.CancelledError):
|
||||
await asyncio.wait_for(active_task, timeout=2)
|
||||
await asyncio.wait_for(wait_for_discard(key), timeout=2)
|
||||
assert loop.sessions.get_cached(key) is None
|
||||
|
||||
loop.stop()
|
||||
await loop.bus.publish_inbound(_message(key, "wake"))
|
||||
await asyncio.wait_for(run_task, timeout=2)
|
||||
@@ -73,3 +73,17 @@ 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_storage(tmp_path) -> None:
|
||||
manager = SessionManager(tmp_path)
|
||||
session = manager.get_or_create_transient("websocket:temporary-test")
|
||||
session.add_message("user", "secret")
|
||||
|
||||
manager.save(session, fsync=True)
|
||||
|
||||
assert manager.get_cached(session.key) is session
|
||||
assert manager.read_session_file(session.key) is None
|
||||
assert list(manager.sessions_dir.glob("*.jsonl")) == []
|
||||
manager.invalidate(session.key)
|
||||
assert manager.get_cached(session.key) is None
|
||||
|
||||
+213
-22
@@ -61,7 +61,11 @@ import {
|
||||
createRuntimeHost,
|
||||
toRuntimeSurface,
|
||||
} from "@/lib/runtime";
|
||||
import { projectNameFromPath } from "@/lib/workspace";
|
||||
import { projectNameFromPath, scopeWithAccessMode } from "@/lib/workspace";
|
||||
import {
|
||||
createTemporaryChatSession,
|
||||
deriveTemporaryChatTitle,
|
||||
} from "@/lib/temporary-chat";
|
||||
|
||||
type BootState =
|
||||
| { status: "loading" }
|
||||
@@ -96,8 +100,8 @@ type ShellRoute = {
|
||||
view: ShellView;
|
||||
activeKey: string | null;
|
||||
settingsSection: SettingsSectionKey;
|
||||
temporary?: boolean;
|
||||
};
|
||||
|
||||
const loadSettingsView = () => import("@/components/settings/SettingsView");
|
||||
const SettingsView = lazy(async () => {
|
||||
const module = await loadSettingsView();
|
||||
@@ -227,6 +231,22 @@ function readShellRoute(): ShellRoute {
|
||||
if (path === "/skills") {
|
||||
return { view: "skills", activeKey, settingsSection: "skills" };
|
||||
}
|
||||
if (path.startsWith("/temporary/")) {
|
||||
const encoded = path.slice("/temporary/".length);
|
||||
try {
|
||||
const chatId = decodeURIComponent(encoded).trim();
|
||||
return chatId
|
||||
? {
|
||||
view: "chat",
|
||||
activeKey: `websocket:${chatId}`,
|
||||
settingsSection: "overview",
|
||||
temporary: true,
|
||||
}
|
||||
: defaultShellRoute();
|
||||
} catch {
|
||||
return defaultShellRoute();
|
||||
}
|
||||
}
|
||||
if (path.startsWith("/chat/")) {
|
||||
const encoded = path.slice("/chat/".length);
|
||||
try {
|
||||
@@ -243,6 +263,10 @@ function readShellRoute(): ShellRoute {
|
||||
|
||||
function shellRouteHash(route: ShellRoute): string {
|
||||
if (route.view === "chat") {
|
||||
if (route.temporary && route.activeKey?.startsWith("websocket:")) {
|
||||
const chatId = route.activeKey.slice("websocket:".length);
|
||||
return `#/temporary/${encodeURIComponent(chatId)}`;
|
||||
}
|
||||
return route.activeKey
|
||||
? `#/chat/${encodeURIComponent(route.activeKey)}`
|
||||
: "#/new";
|
||||
@@ -961,6 +985,8 @@ function Shell({
|
||||
initialRouteRef.current.activeKey,
|
||||
);
|
||||
const [view, setView] = useState<ShellView>(initialRouteRef.current.view);
|
||||
const [temporarySessions, setTemporarySessions] = useState<Record<string, ChatSummary>>({});
|
||||
const [temporaryChatEnabled, setTemporaryChatEnabled] = useState(false);
|
||||
const [settingsInitialSection, setSettingsInitialSection] =
|
||||
useState<SettingsSectionKey>(initialRouteRef.current.settingsSection);
|
||||
const [hostSidebarOpen, setHostSidebarOpen] =
|
||||
@@ -1005,11 +1031,26 @@ function Shell({
|
||||
const runningChatIdsRef = useRef<Set<string>>(new Set());
|
||||
const activeChatIdRef = useRef<string | null>(null);
|
||||
const pendingCreatedSessionKeyRef = useRef<string | null>(null);
|
||||
const temporarySessionsRef = useRef<Record<string, ChatSummary>>({});
|
||||
const hostSidebarPreviewCloseTimerRef = useRef<number | null>(null);
|
||||
const effectiveRuntimeSurface =
|
||||
settingsSnapshot?.surface ?? settingsSnapshot?.runtime_surface ?? runtimeSurface;
|
||||
const showHostChrome = effectiveRuntimeSurface === "native";
|
||||
const showMainSidebar = view !== "settings";
|
||||
const activeTemporarySession = activeKey ? temporarySessions[activeKey] ?? null : null;
|
||||
const temporaryChatId = activeTemporarySession?.chatId ?? null;
|
||||
const temporaryChatActive = view === "chat" && temporaryChatId !== null;
|
||||
const temporaryChatRequested = temporaryChatActive || temporaryChatEnabled;
|
||||
const temporarySessionList = useMemo(
|
||||
() => Object.values(temporarySessions).sort((a, b) => (
|
||||
Date.parse(b.createdAt ?? "") - Date.parse(a.createdAt ?? "")
|
||||
)),
|
||||
[temporarySessions],
|
||||
);
|
||||
const temporaryChatIds = useMemo(
|
||||
() => temporarySessionList.map((session) => session.chatId),
|
||||
[temporarySessionList],
|
||||
);
|
||||
|
||||
const navigate = useCallback(
|
||||
(route: ShellRoute, options?: { replace?: boolean }) => {
|
||||
@@ -1036,6 +1077,21 @@ function Shell({
|
||||
return () => window.removeEventListener("hashchange", applyRoute);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
temporarySessionsRef.current = temporarySessions;
|
||||
}, [temporarySessions]);
|
||||
|
||||
useEffect(() => {
|
||||
if (view === "chat" && !activeKey) return;
|
||||
setTemporaryChatEnabled(false);
|
||||
}, [activeKey, view]);
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const session of Object.values(temporarySessionsRef.current)) {
|
||||
client.discardTemporaryChat(session.chatId);
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchSettings(getToken())
|
||||
@@ -1121,8 +1177,9 @@ function Shell({
|
||||
|
||||
const activeSession = useMemo<ChatSummary | null>(() => {
|
||||
if (!activeKey) return null;
|
||||
if (temporarySessions[activeKey]) return temporarySessions[activeKey];
|
||||
return sessions.find((s) => s.key === activeKey) ?? null;
|
||||
}, [sessions, activeKey]);
|
||||
}, [sessions, activeKey, temporarySessions]);
|
||||
const runningChatIdList = useMemo(() => Array.from(runningChatIds), [runningChatIds]);
|
||||
const updatedChatIdList = useMemo(() => Array.from(updatedChatIds), [updatedChatIds]);
|
||||
const activeChatId = activeSession?.chatId ?? null;
|
||||
@@ -1137,6 +1194,11 @@ function Shell({
|
||||
});
|
||||
}, [activeChatId]);
|
||||
const activeWorkspaceScope = useMemo<WorkspaceScopePayload | null>(() => {
|
||||
if (temporaryChatRequested) {
|
||||
return workspaces?.default_scope
|
||||
? normalizeWorkspaceScope(scopeWithAccessMode(workspaces.default_scope, "restricted"))
|
||||
: null;
|
||||
}
|
||||
if (activeChatId && workspaceOverrides[activeChatId]) {
|
||||
return workspaceOverrides[activeChatId];
|
||||
}
|
||||
@@ -1148,6 +1210,7 @@ function Shell({
|
||||
activeChatId,
|
||||
activeSession?.workspaceScope,
|
||||
draftWorkspaceScope,
|
||||
temporaryChatRequested,
|
||||
workspaceOverrides,
|
||||
workspaces?.default_scope,
|
||||
]);
|
||||
@@ -1187,11 +1250,17 @@ function Shell({
|
||||
if (pendingCreatedKey && sessions.some((session) => session.key === pendingCreatedKey)) {
|
||||
pendingCreatedSessionKeyRef.current = null;
|
||||
}
|
||||
if (!activeKey || sessions.some((session) => session.key === activeKey)) return;
|
||||
if (!activeKey) return;
|
||||
const currentRoute = readShellRoute();
|
||||
if (currentRoute.temporary) {
|
||||
if (temporarySessions[activeKey]) return;
|
||||
navigate(defaultShellRoute(), { replace: true });
|
||||
return;
|
||||
}
|
||||
if (sessions.some((session) => session.key === activeKey)) return;
|
||||
// WebKit can commit the route before useSessions' optimistic insert.
|
||||
// Keep that just-created destination valid until the session list catches up.
|
||||
if (pendingCreatedKey === activeKey) return;
|
||||
const currentRoute = readShellRoute();
|
||||
navigate(
|
||||
currentRoute.view === "chat"
|
||||
? defaultShellRoute()
|
||||
@@ -1201,7 +1270,7 @@ function Shell({
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [activeKey, loading, navigate, sessions]);
|
||||
}, [activeKey, loading, navigate, sessions, temporarySessions]);
|
||||
|
||||
useEffect(() => {
|
||||
return client.onSessionUpdate((chatId, scope, workspaceScope) => {
|
||||
@@ -1360,14 +1429,22 @@ function Shell({
|
||||
const next = normalizeWorkspaceScope(scope);
|
||||
setWorkspaceError(null);
|
||||
if (activeChatId) {
|
||||
if (!activeChatRunning) {
|
||||
if (temporaryChatActive) {
|
||||
setTemporarySessions((current) => {
|
||||
if (!activeKey || !current[activeKey]) return current;
|
||||
return {
|
||||
...current,
|
||||
[activeKey]: { ...current[activeKey], workspaceScope: next },
|
||||
};
|
||||
});
|
||||
} else if (!activeChatRunning) {
|
||||
client.setWorkspaceScope(activeChatId, next);
|
||||
}
|
||||
return;
|
||||
}
|
||||
setDraftWorkspaceScope(next);
|
||||
},
|
||||
[activeChatId, activeChatRunning, client],
|
||||
[activeChatId, activeChatRunning, activeKey, client, temporaryChatActive],
|
||||
);
|
||||
|
||||
const onCreateChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null) => {
|
||||
@@ -1398,6 +1475,45 @@ function Shell({
|
||||
}
|
||||
}, [activeWorkspaceScope, createChat, navigate, t]);
|
||||
|
||||
const onCreateTemporaryChat = useCallback(
|
||||
async (
|
||||
workspaceScope?: WorkspaceScopePayload | null,
|
||||
initialMessage?: string,
|
||||
) => {
|
||||
try {
|
||||
const chatId = await client.newTemporaryChat();
|
||||
const session = createTemporaryChatSession(chatId);
|
||||
const restrictedScope = workspaceScope
|
||||
? normalizeWorkspaceScope(scopeWithAccessMode(workspaceScope, "restricted"))
|
||||
: null;
|
||||
const nextSession: ChatSummary = {
|
||||
...session,
|
||||
preview: initialMessage ?? "",
|
||||
...(restrictedScope ? { workspaceScope: restrictedScope } : {}),
|
||||
};
|
||||
setTemporarySessions((current) => ({
|
||||
...current,
|
||||
[nextSession.key]: nextSession,
|
||||
}));
|
||||
setTemporaryChatEnabled(false);
|
||||
setWorkspaceError(null);
|
||||
setSessionSearchOpen(false);
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: nextSession.key,
|
||||
settingsSection: "overview",
|
||||
temporary: true,
|
||||
});
|
||||
setMobileSidebarOpen(false);
|
||||
return nextSession.chatId;
|
||||
} catch (error) {
|
||||
console.error("Failed to create temporary chat", error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[client, navigate],
|
||||
);
|
||||
|
||||
const onForkChat = useCallback(async (
|
||||
sourceChatId: string,
|
||||
beforeUserIndex: number,
|
||||
@@ -1427,12 +1543,20 @@ function Shell({
|
||||
|
||||
const onNewChat = useCallback(() => {
|
||||
navigate(defaultShellRoute());
|
||||
setTemporaryChatEnabled(false);
|
||||
setDraftWorkspaceScope(null);
|
||||
setWorkspaceError(null);
|
||||
setSessionSearchOpen(false);
|
||||
setMobileSidebarOpen(false);
|
||||
}, [navigate]);
|
||||
|
||||
const onTemporaryChatEnabledChange = useCallback((enabled: boolean) => {
|
||||
if (view !== "chat" || activeKey) return;
|
||||
setTemporaryChatEnabled(enabled);
|
||||
setDraftWorkspaceScope(null);
|
||||
setWorkspaceError(null);
|
||||
}, [activeKey, view]);
|
||||
|
||||
const onNewChatInProject = useCallback(
|
||||
(projectPath: string, projectName: string) => {
|
||||
const base = workspaces?.default_scope ?? activeWorkspaceScope;
|
||||
@@ -1441,6 +1565,7 @@ function Shell({
|
||||
onNewChat();
|
||||
return;
|
||||
}
|
||||
setTemporaryChatEnabled(false);
|
||||
navigate(defaultShellRoute());
|
||||
setDraftWorkspaceScope(normalizeWorkspaceScope({
|
||||
project_path: trimmed,
|
||||
@@ -1456,7 +1581,9 @@ function Shell({
|
||||
|
||||
const onSelectChat = useCallback(
|
||||
(key: string) => {
|
||||
const selected = sessions.find((session) => session.key === key);
|
||||
const selectedTemporary = temporarySessionsRef.current[key];
|
||||
const selected = selectedTemporary
|
||||
?? sessions.find((session) => session.key === key);
|
||||
const selectedChatId = selected?.chatId;
|
||||
if (selectedChatId) {
|
||||
setUpdatedChatIds((current) => {
|
||||
@@ -1472,12 +1599,38 @@ function Shell({
|
||||
setDraftWorkspaceScope(null);
|
||||
}
|
||||
setWorkspaceError(null);
|
||||
navigate({ view: "chat", activeKey: key, settingsSection: "overview" });
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: key,
|
||||
settingsSection: "overview",
|
||||
...(selectedTemporary ? { temporary: true } : {}),
|
||||
});
|
||||
setMobileSidebarOpen(false);
|
||||
},
|
||||
[navigate, sessions],
|
||||
);
|
||||
|
||||
const onCloseTemporaryChat = useCallback((key: string) => {
|
||||
const session = temporarySessionsRef.current[key];
|
||||
if (!session) return;
|
||||
const remaining = temporarySessionList.filter((item) => item.key !== key);
|
||||
const nextSessions = Object.fromEntries(remaining.map((item) => [item.key, item]));
|
||||
temporarySessionsRef.current = nextSessions;
|
||||
setTemporarySessions(nextSessions);
|
||||
client.discardTemporaryChat(session.chatId);
|
||||
if (activeKey === key) {
|
||||
if (remaining.length === 0) setDraftWorkspaceScope(null);
|
||||
setWorkspaceError(null);
|
||||
navigate({
|
||||
view: "chat",
|
||||
activeKey: remaining[0]?.key ?? null,
|
||||
settingsSection: "overview",
|
||||
...(remaining[0] ? { temporary: true } : {}),
|
||||
}, { replace: true });
|
||||
}
|
||||
setMobileSidebarOpen(false);
|
||||
}, [activeKey, client, navigate, temporarySessionList]);
|
||||
|
||||
const onTogglePin = useCallback(
|
||||
(key: string) => {
|
||||
void updateSidebarState((current) => {
|
||||
@@ -1760,6 +1913,11 @@ function Shell({
|
||||
nextRunning.delete(chatId);
|
||||
runningChatIdsRef.current = nextRunning;
|
||||
setRunningChatIds(nextRunning);
|
||||
if (
|
||||
Object.values(temporarySessionsRef.current).some(
|
||||
(session) => session.chatId === chatId,
|
||||
)
|
||||
) return;
|
||||
setUpdatedChatIds((current) => {
|
||||
const next = new Set(current);
|
||||
if (activeChatIdRef.current === chatId) {
|
||||
@@ -1772,6 +1930,24 @@ function Shell({
|
||||
});
|
||||
}, [client]);
|
||||
|
||||
useEffect(() => {
|
||||
let wasOpen = client.status === "open";
|
||||
return client.onStatus((status) => {
|
||||
if (status === "open") {
|
||||
wasOpen = true;
|
||||
return;
|
||||
}
|
||||
if (!wasOpen) return;
|
||||
wasOpen = false;
|
||||
if (Object.keys(temporarySessionsRef.current).length === 0) return;
|
||||
temporarySessionsRef.current = {};
|
||||
setTemporarySessions({});
|
||||
if (readShellRoute().temporary) {
|
||||
navigate(defaultShellRoute(), { replace: true });
|
||||
}
|
||||
});
|
||||
}, [client, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
return client.onStatus((status) => {
|
||||
const startedAt = (() => {
|
||||
@@ -1800,7 +1976,10 @@ function Shell({
|
||||
});
|
||||
}, [client, t]);
|
||||
|
||||
const onTurnEnd = useDeferredTitleRefresh(activeSession, refresh);
|
||||
const onTurnEnd = useDeferredTitleRefresh(
|
||||
temporaryChatActive ? null : activeSession,
|
||||
refresh,
|
||||
);
|
||||
|
||||
const onConfirmDelete = useCallback(async () => {
|
||||
if (!pendingDelete) return;
|
||||
@@ -1890,7 +2069,9 @@ function Shell({
|
||||
});
|
||||
}, []);
|
||||
|
||||
const headerTitle = activeSession
|
||||
const headerTitle = temporaryChatActive
|
||||
? deriveTemporaryChatTitle(activeSession?.preview, t("temporaryChat.title"))
|
||||
: activeSession
|
||||
? sidebarState.title_overrides[activeSession.key] ||
|
||||
activeSession.title ||
|
||||
deriveTitle(activeSession.preview, t("chat.newChat"))
|
||||
@@ -1928,11 +2109,13 @@ function Shell({
|
||||
|
||||
const sidebarProps = {
|
||||
sessions,
|
||||
temporarySessions: temporarySessionList,
|
||||
activeKey: view === "chat" ? activeKey : null,
|
||||
loading,
|
||||
newChatActive: view === "chat" && activeKey === null,
|
||||
onNewChat,
|
||||
onSelect: onSelectChat,
|
||||
onCloseTemporaryChat,
|
||||
onRequestDelete,
|
||||
onTogglePin,
|
||||
onRequestRename,
|
||||
@@ -2118,10 +2301,16 @@ function Shell({
|
||||
session={activeSession}
|
||||
sessions={sessions}
|
||||
title={headerTitle}
|
||||
temporary={temporaryChatRequested}
|
||||
temporaryChatIds={temporaryChatIds}
|
||||
temporaryChatEnabled={temporaryChatEnabled}
|
||||
onTemporaryChatEnabledChange={
|
||||
!activeKey ? onTemporaryChatEnabledChange : undefined
|
||||
}
|
||||
onToggleSidebar={toggleSidebar}
|
||||
onNewChat={onNewChat}
|
||||
onCreateChat={onCreateChat}
|
||||
onForkChat={onForkChat}
|
||||
onCreateChat={temporaryChatEnabled ? onCreateTemporaryChat : onCreateChat}
|
||||
onForkChat={temporaryChatActive ? undefined : onForkChat}
|
||||
onTurnEnd={onTurnEnd}
|
||||
theme={theme}
|
||||
onToggleTheme={toggle}
|
||||
@@ -2200,14 +2389,16 @@ function Shell({
|
||||
</Suspense>
|
||||
) : null}
|
||||
{restartToast ? (
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
floatingSurfaceElevationClassName,
|
||||
"fixed left-1/2 top-[calc(0.75rem+env(safe-area-inset-top))] z-50 max-w-[calc(100vw-1rem)] -translate-x-1/2 rounded-full px-4 py-2 text-sm font-medium",
|
||||
)}
|
||||
>
|
||||
{restartToast}
|
||||
<div className="fixed left-1/2 top-[calc(0.75rem+env(safe-area-inset-top))] z-50 flex w-[min(32rem,calc(100vw-1rem))] -translate-x-1/2 flex-col items-center gap-2">
|
||||
<div
|
||||
role="status"
|
||||
className={cn(
|
||||
floatingSurfaceElevationClassName,
|
||||
"max-w-full rounded-full px-4 py-2 text-sm font-medium",
|
||||
)}
|
||||
>
|
||||
{restartToast}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<PairingCodePopup
|
||||
|
||||
@@ -4,17 +4,20 @@ import {
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from "react";
|
||||
import {
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
Folder,
|
||||
MessageCircleDashed,
|
||||
MoreHorizontal,
|
||||
Pencil,
|
||||
Pin,
|
||||
PinOff,
|
||||
Plus,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -41,6 +44,7 @@ import {
|
||||
type ChatGroupLabels,
|
||||
} from "@/lib/chat-groups";
|
||||
import { clearDraggedSession, writeDraggedSession } from "@/lib/session-drag";
|
||||
import { deriveTemporaryChatTitle } from "@/lib/temporary-chat";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
|
||||
|
||||
@@ -50,8 +54,10 @@ const ACTION_MENU_CONTENT_CLASS = "w-[8.5rem] min-w-[8.5rem]";
|
||||
|
||||
interface ChatListProps {
|
||||
sessions: ChatSummary[];
|
||||
temporarySessions?: ChatSummary[];
|
||||
activeKey: string | null;
|
||||
onSelect: (key: string) => void;
|
||||
onCloseTemporaryChat?: (key: string) => void;
|
||||
onRequestDelete: (key: string, label: string) => void;
|
||||
onTogglePin: (key: string) => void;
|
||||
onRequestRename: (key: string, label: string) => void;
|
||||
@@ -81,8 +87,10 @@ interface ChatListProps {
|
||||
|
||||
export const ChatList = memo(function ChatList({
|
||||
sessions,
|
||||
temporarySessions = [],
|
||||
activeKey,
|
||||
onSelect,
|
||||
onCloseTemporaryChat,
|
||||
onRequestDelete,
|
||||
onTogglePin,
|
||||
onRequestRename,
|
||||
@@ -188,7 +196,7 @@ export const ChatList = memo(function ChatList({
|
||||
setVisibleLimit(INITIAL_VISIBLE_SESSIONS);
|
||||
}, [showArchived, sort]);
|
||||
|
||||
if (loading && sessions.length === 0) {
|
||||
if (loading && sessions.length === 0 && temporarySessions.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-6 text-[12px] text-muted-foreground">
|
||||
{t("chat.loading")}
|
||||
@@ -196,7 +204,7 @@ export const ChatList = memo(function ChatList({
|
||||
);
|
||||
}
|
||||
|
||||
if (sessions.length === 0) {
|
||||
if (sessions.length === 0 && temporarySessions.length === 0) {
|
||||
return (
|
||||
<div className="px-3 py-6 text-[12px] leading-5 text-muted-foreground/80">
|
||||
{emptyLabel ?? t("chat.noSessions")}
|
||||
@@ -237,6 +245,16 @@ export const ChatList = memo(function ChatList({
|
||||
data-chat-list-content
|
||||
className="relative min-w-0 space-y-3 px-2 py-1.5"
|
||||
>
|
||||
{temporarySessions.length > 0 ? (
|
||||
<TemporaryChatSection
|
||||
sessions={temporarySessions}
|
||||
activeKey={activeKey}
|
||||
activeRowRef={activeRowRef}
|
||||
running={running}
|
||||
onSelect={onSelect}
|
||||
onClose={onCloseTemporaryChat}
|
||||
/>
|
||||
) : null}
|
||||
{limitedGroups.map((group, index) => {
|
||||
const foldableChatsGroup = isFoldableChatsGroup(group);
|
||||
const foldedChatsGroup = isFoldedChatsGroup(group, collapsedGroups);
|
||||
@@ -497,6 +515,78 @@ export const ChatList = memo(function ChatList({
|
||||
);
|
||||
});
|
||||
|
||||
function TemporaryChatSection({
|
||||
sessions,
|
||||
activeKey,
|
||||
activeRowRef,
|
||||
running,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
sessions: ChatSummary[];
|
||||
activeKey: string | null;
|
||||
activeRowRef: RefObject<HTMLDivElement>;
|
||||
running: ReadonlySet<string>;
|
||||
onSelect: (key: string) => void;
|
||||
onClose?: (key: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<section aria-label={t("temporaryChat.sectionTitle")} className="relative z-[1]">
|
||||
<ChatsGroupHeader label={t("temporaryChat.sectionTitle")} />
|
||||
<ul className="space-y-0.5">
|
||||
{sessions.map((session) => {
|
||||
const active = session.key === activeKey;
|
||||
const title = deriveTemporaryChatTitle(session.preview, t("temporaryChat.title"));
|
||||
return (
|
||||
<li key={session.key} className="min-w-0">
|
||||
<div
|
||||
ref={active ? activeRowRef : undefined}
|
||||
data-temporary-chat-row={session.key}
|
||||
className={cn(
|
||||
"group flex min-h-8 min-w-0 max-w-full items-center gap-2 rounded-xl px-2 text-[13px]",
|
||||
SIDEBAR_SELECTION_ITEM_CLASS,
|
||||
active
|
||||
? "text-sidebar-accent-foreground"
|
||||
: "text-sidebar-foreground/82 hover:bg-sidebar-foreground/[0.035] hover:text-sidebar-foreground dark:hover:bg-white/[0.05]",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelect(session.key)}
|
||||
aria-current={active ? "page" : undefined}
|
||||
title={title}
|
||||
className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden py-1.5 text-left"
|
||||
>
|
||||
<MessageCircleDashed
|
||||
className="h-3.5 w-3.5 shrink-0 text-[hsl(var(--temporary-foreground))]"
|
||||
aria-hidden
|
||||
/>
|
||||
<span className="min-w-0 flex-1 truncate font-medium leading-5">
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
<SessionActivityIndicator state={running.has(session.chatId) ? "running" : null} />
|
||||
{onClose ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("temporaryChat.closeAction", { title })}
|
||||
onClick={() => onClose(session.key)}
|
||||
className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-muted-foreground/60 transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/60"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" aria-hidden />
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ProjectGroupHeader({
|
||||
label,
|
||||
path,
|
||||
|
||||
@@ -52,6 +52,8 @@ import type {
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: UIMessage;
|
||||
/** Give temporary-chat user turns the dashed private-mode treatment. */
|
||||
temporary?: boolean;
|
||||
/** When false, hide this message's copy button. Default true. */
|
||||
showCopyAction?: boolean;
|
||||
cliApps?: CliAppInfo[];
|
||||
@@ -258,6 +260,7 @@ function UserDeliveryStatus({
|
||||
/** Render user turns as compact bubbles and assistant turns as document-like prose. */
|
||||
export function MessageBubble({
|
||||
message,
|
||||
temporary = false,
|
||||
showCopyAction = true,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
@@ -326,9 +329,13 @@ export function MessageBubble({
|
||||
) : null}
|
||||
{hasText ? (
|
||||
<p
|
||||
data-temporary-message={temporary ? "true" : undefined}
|
||||
className={cn(
|
||||
"ml-auto w-fit max-w-full min-w-0 rounded-[18px] bg-secondary/70 px-4 py-2",
|
||||
"ml-auto w-fit max-w-full min-w-0 rounded-[18px] px-4 py-2",
|
||||
"text-left text-[16px]/[1.75] whitespace-pre-wrap [overflow-wrap:anywhere]",
|
||||
temporary
|
||||
? "border border-dashed border-muted-foreground/40 bg-transparent"
|
||||
: "bg-secondary/70",
|
||||
)}
|
||||
>
|
||||
{messageText}
|
||||
|
||||
@@ -31,11 +31,13 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
interface SidebarProps {
|
||||
sessions: ChatSummary[];
|
||||
temporarySessions?: ChatSummary[];
|
||||
activeKey: string | null;
|
||||
loading: boolean;
|
||||
newChatActive: boolean;
|
||||
onNewChat: () => void;
|
||||
onSelect: (key: string) => void;
|
||||
onCloseTemporaryChat?: (key: string) => void;
|
||||
onRequestDelete: (key: string, label: string) => void;
|
||||
onTogglePin: (key: string) => void;
|
||||
onRequestRename: (key: string, label: string) => void;
|
||||
@@ -221,10 +223,12 @@ export function Sidebar(props: SidebarProps) {
|
||||
{!collapsed && (
|
||||
<ChatList
|
||||
sessions={props.sessions}
|
||||
temporarySessions={props.temporarySessions}
|
||||
activeKey={props.activeKey}
|
||||
loading={props.loading}
|
||||
emptyLabel={t("chat.noSessions")}
|
||||
onSelect={props.onSelect}
|
||||
onCloseTemporaryChat={props.onCloseTemporaryChat}
|
||||
onRequestDelete={props.onRequestDelete}
|
||||
onTogglePin={props.onTogglePin}
|
||||
onRequestRename={props.onRequestRename}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type Ref,
|
||||
} from "react";
|
||||
|
||||
import { MarkdownText, preloadMarkdownText } from "@/components/MarkdownText";
|
||||
@@ -199,12 +200,14 @@ interface ThreadComposerProps {
|
||||
sessions?: ChatSummary[];
|
||||
skills?: SkillSummary[];
|
||||
onStop?: () => void;
|
||||
surfaceRef?: Ref<HTMLDivElement>;
|
||||
onTranscribeAudio?: (dataUrl: string, options?: { durationMs?: number }) => Promise<string>;
|
||||
/** Unix seconds from server; turn elapsed timer above input while set. */
|
||||
runStartedAt?: number | null;
|
||||
/** Sustained objective for this chat (WebSocket ``goal_state``). */
|
||||
goalState?: GoalStateWsPayload;
|
||||
workspaceScope?: WorkspaceScopePayload | null;
|
||||
workspaceControlsHidden?: boolean;
|
||||
workspaceDefaultScope?: WorkspaceScopePayload | null;
|
||||
workspaceControls?: WorkspacesPayload["controls"] | null;
|
||||
workspaceScopeDisabled?: boolean;
|
||||
@@ -951,10 +954,12 @@ export function ThreadComposer({
|
||||
sessions = [],
|
||||
skills = [],
|
||||
onStop,
|
||||
surfaceRef,
|
||||
onTranscribeAudio,
|
||||
runStartedAt = null,
|
||||
goalState,
|
||||
workspaceScope = null,
|
||||
workspaceControlsHidden = false,
|
||||
workspaceDefaultScope = null,
|
||||
workspaceControls = null,
|
||||
workspaceScopeDisabled = false,
|
||||
@@ -1005,17 +1010,18 @@ export function ThreadComposer({
|
||||
() => queuedPromptsStorageKey(pendingQueueKey),
|
||||
[pendingQueueKey],
|
||||
);
|
||||
const showProjectPicker =
|
||||
const projectPickerAvailable =
|
||||
isHero
|
||||
&& !!workspaceDefaultScope
|
||||
&& !!onWorkspaceScopeChange
|
||||
&& workspaceControls?.can_change_project !== false;
|
||||
const showProjectPicker = projectPickerAvailable && !workspaceControlsHidden;
|
||||
|
||||
useEffect(() => {
|
||||
secondEnterPromptIdRef.current = null;
|
||||
skipQueuedPromptPersistRef.current = true;
|
||||
setQueuedPrompts(queuedPromptStorageKey ? readQueuedPrompts(queuedPromptStorageKey) : []);
|
||||
}, [queuedPromptStorageKey]);
|
||||
}, [pendingQueueKey, queuedPromptStorageKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!queuedPromptStorageKey) return;
|
||||
@@ -2241,6 +2247,7 @@ export function ThreadComposer({
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
ref={surfaceRef}
|
||||
className={cn(
|
||||
"thread-composer-surface group/composer relative mx-auto flex w-full flex-col overflow-visible transition-all duration-200",
|
||||
isHero
|
||||
@@ -2386,7 +2393,7 @@ export function ThreadComposer({
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"thread-composer-footer flex flex-nowrap items-center",
|
||||
"thread-composer-footer flex flex-nowrap items-center motion-safe:transition-[padding-bottom] motion-safe:[transition-duration:220ms] motion-safe:ease-in-out",
|
||||
isHero
|
||||
? cn(
|
||||
"gap-x-1.5 px-3 sm:px-4",
|
||||
@@ -2433,7 +2440,7 @@ export function ThreadComposer({
|
||||
isHero={isHero}
|
||||
levels={voiceRecorder.levels}
|
||||
/>
|
||||
) : workspaceScope ? (
|
||||
) : workspaceScope && !workspaceControlsHidden ? (
|
||||
<WorkspaceAccessMenu
|
||||
scope={workspaceScope}
|
||||
disabled={disabled || workspaceScopeDisabled}
|
||||
@@ -2544,15 +2551,28 @@ export function ThreadComposer({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<WorkspaceProjectPicker
|
||||
isHero={isHero}
|
||||
disabled={disabled || workspaceScopeDisabled}
|
||||
scope={workspaceScope}
|
||||
defaultScope={workspaceDefaultScope}
|
||||
controls={workspaceControls}
|
||||
error={workspaceError}
|
||||
onChange={onWorkspaceScopeChange}
|
||||
/>
|
||||
{projectPickerAvailable ? (
|
||||
<div
|
||||
className="composer-workspace-drawer"
|
||||
data-composer-workspace-drawer=""
|
||||
data-state={showProjectPicker ? "open" : "closed"}
|
||||
aria-hidden={showProjectPicker ? undefined : true}
|
||||
>
|
||||
<div className="composer-workspace-drawer-clip">
|
||||
<div className="composer-workspace-drawer-content">
|
||||
<WorkspaceProjectPicker
|
||||
isHero={isHero}
|
||||
disabled={disabled || workspaceScopeDisabled || !showProjectPicker}
|
||||
scope={workspaceScope}
|
||||
defaultScope={workspaceDefaultScope}
|
||||
controls={workspaceControls}
|
||||
error={workspaceError}
|
||||
onChange={onWorkspaceScopeChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { Menu, Moon, Sun } from "lucide-react";
|
||||
import type { ReactNode } from "react";
|
||||
import { Menu, MessageCircleDashed, Moon, Sun } from "lucide-react";
|
||||
import { type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ThreadHeaderProps {
|
||||
@@ -16,6 +22,9 @@ interface ThreadHeaderProps {
|
||||
minimal?: boolean;
|
||||
promptNavigatorAction?: ReactNode;
|
||||
sessionInfoAction?: ReactNode;
|
||||
temporaryChatEnabled?: boolean;
|
||||
temporaryChatDisabled?: boolean;
|
||||
onTemporaryChatEnabledChange?: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
export function ThreadHeader({
|
||||
@@ -29,13 +38,17 @@ export function ThreadHeader({
|
||||
minimal = false,
|
||||
promptNavigatorAction,
|
||||
sessionInfoAction,
|
||||
temporaryChatEnabled = false,
|
||||
temporaryChatDisabled = false,
|
||||
onTemporaryChatEnabledChange,
|
||||
}: ThreadHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-testid="thread-header"
|
||||
className={cn(
|
||||
"relative z-10 flex items-center justify-between gap-3 px-3 py-2",
|
||||
"relative z-30 flex items-center justify-between gap-3 px-3 py-2",
|
||||
minimal && "h-11",
|
||||
!minimal && hostChromeTitleInset && "lg:pl-[128px]",
|
||||
)}
|
||||
@@ -63,6 +76,45 @@ export function ThreadHeader({
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{sessionInfoAction}
|
||||
{promptNavigatorAction}
|
||||
{onTemporaryChatEnabledChange ? (
|
||||
<TooltipProvider delayDuration={700} skipDelayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
disabled={temporaryChatDisabled}
|
||||
aria-label={t("temporaryChat.title")}
|
||||
aria-pressed={temporaryChatEnabled}
|
||||
onClick={() => onTemporaryChatEnabledChange(!temporaryChatEnabled)}
|
||||
className={cn(
|
||||
"host-no-drag h-8 w-8 shrink-0 rounded-full bg-transparent text-muted-foreground shadow-none transition-none hover:text-foreground",
|
||||
temporaryChatEnabled ? "hover:bg-transparent" : "hover:bg-accent/45",
|
||||
)}
|
||||
>
|
||||
<MessageCircleDashed
|
||||
data-testid="temporary-chat-icon"
|
||||
className={cn(
|
||||
"h-4 w-4 motion-safe:transition-colors",
|
||||
temporaryChatEnabled
|
||||
? "text-[var(--temporary-control-active)] motion-safe:duration-150"
|
||||
: "text-current motion-safe:duration-75",
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="bottom"
|
||||
align="end"
|
||||
className="max-w-72 rounded-xl border border-border/70 bg-popover px-3 py-2 text-[12px]/[1.4] text-popover-foreground shadow-[0_8px_24px_rgba(15,23,42,0.13)] dark:border-white/10"
|
||||
>
|
||||
{t("temporaryChat.description")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
) : null}
|
||||
{!hideThemeButton ? (
|
||||
<ThemeButton
|
||||
theme={theme}
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { CliAppInfo, McpPresetInfo, SlashCommand, UIMessage } from "@/lib/t
|
||||
|
||||
interface ThreadMessagesProps {
|
||||
messages: UIMessage[];
|
||||
temporary?: boolean;
|
||||
/** When true, agent turn still in flight — keeps activity timeline expanded. */
|
||||
isStreaming?: boolean;
|
||||
hiddenUserMessageCount?: number;
|
||||
@@ -50,6 +51,7 @@ export function assistantForkFlags(units: DisplayUnit[]): boolean[] {
|
||||
|
||||
export function ThreadMessages({
|
||||
messages,
|
||||
temporary = false,
|
||||
isStreaming = false,
|
||||
hiddenUserMessageCount = 0,
|
||||
cliApps = [],
|
||||
@@ -125,6 +127,7 @@ export function ThreadMessages({
|
||||
forkIndex={forkIndex}
|
||||
showForkBoundary={index === forkBoundaryAfterUnitIndex}
|
||||
forkBoundaryLabel={t("thread.forkedFromHistory")}
|
||||
temporary={temporary}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={slashCommands}
|
||||
@@ -147,6 +150,7 @@ interface ThreadDisplayUnitProps {
|
||||
forkIndex?: number;
|
||||
showForkBoundary: boolean;
|
||||
forkBoundaryLabel: string;
|
||||
temporary: boolean;
|
||||
cliApps: CliAppInfo[];
|
||||
mcpPresets: McpPresetInfo[];
|
||||
slashCommands: SlashCommand[];
|
||||
@@ -164,6 +168,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
||||
forkIndex,
|
||||
showForkBoundary,
|
||||
forkBoundaryLabel,
|
||||
temporary,
|
||||
cliApps,
|
||||
mcpPresets,
|
||||
slashCommands,
|
||||
@@ -200,6 +205,7 @@ const ThreadDisplayUnit = memo(function ThreadDisplayUnit({
|
||||
) : (
|
||||
<MessageBubble
|
||||
message={unit.message}
|
||||
temporary={temporary}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={slashCommands}
|
||||
@@ -227,6 +233,7 @@ function threadDisplayUnitPropsEqual(
|
||||
&& previous.forkIndex === next.forkIndex
|
||||
&& previous.showForkBoundary === next.showForkBoundary
|
||||
&& previous.forkBoundaryLabel === next.forkBoundaryLabel
|
||||
&& previous.temporary === next.temporary
|
||||
&& previous.cliApps === next.cliApps
|
||||
&& previous.mcpPresets === next.mcpPresets
|
||||
&& previous.slashCommands === next.slashCommands
|
||||
|
||||
@@ -295,10 +295,17 @@ interface ThreadShellProps {
|
||||
session: ChatSummary | null;
|
||||
sessions?: ChatSummary[];
|
||||
title: string;
|
||||
temporary?: boolean;
|
||||
temporaryChatIds?: readonly string[];
|
||||
temporaryChatEnabled?: boolean;
|
||||
onTemporaryChatEnabledChange?: (enabled: boolean) => void;
|
||||
onToggleSidebar: () => void;
|
||||
onGoHome?: () => void;
|
||||
onNewChat?: () => void;
|
||||
onCreateChat?: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string | null>;
|
||||
onCreateChat?: (
|
||||
workspaceScope?: WorkspaceScopePayload | null,
|
||||
initialMessage?: string,
|
||||
) => Promise<string | null>;
|
||||
onForkChat?: (sourceChatId: string, beforeUserIndex: number) => Promise<string | null>;
|
||||
onTurnEnd?: () => void;
|
||||
theme?: "light" | "dark";
|
||||
@@ -477,7 +484,7 @@ function HeroGreeting({ text }: { text: string }) {
|
||||
<h1
|
||||
ref={headingRef}
|
||||
data-testid="hero-greeting"
|
||||
className="whitespace-nowrap text-[34px] font-normal leading-[1.08] tracking-normal text-foreground sm:text-[48px] sm:leading-tight"
|
||||
className="select-none whitespace-nowrap text-[34px] font-normal leading-[1.08] tracking-normal text-foreground sm:text-[48px] sm:leading-tight"
|
||||
>
|
||||
{text}
|
||||
</h1>
|
||||
@@ -580,6 +587,10 @@ export function ThreadShell({
|
||||
session,
|
||||
sessions = [],
|
||||
title,
|
||||
temporary = false,
|
||||
temporaryChatIds = [],
|
||||
temporaryChatEnabled = false,
|
||||
onTemporaryChatEnabledChange,
|
||||
onToggleSidebar,
|
||||
onCreateChat,
|
||||
onForkChat,
|
||||
@@ -602,7 +613,7 @@ export function ThreadShell({
|
||||
}: ThreadShellProps) {
|
||||
const { t } = useTranslation();
|
||||
const chatId = session?.chatId ?? null;
|
||||
const historyKey = session?.key ?? null;
|
||||
const historyKey = temporary ? null : session?.key ?? null;
|
||||
const mentionSessions = useMemo(
|
||||
() => sessions.filter((candidate) => (
|
||||
candidate.key !== historyKey
|
||||
@@ -657,6 +668,7 @@ export function ThreadShell({
|
||||
const [quotedContext, setQuotedContext] = useState<string | null>(null);
|
||||
const [composerFocusSignal, setComposerFocusSignal] = useState(0);
|
||||
const shellRef = useRef<HTMLElement | null>(null);
|
||||
const composerSurfaceRef = useRef<HTMLDivElement | null>(null);
|
||||
const filePreviewWidthRef = useRef(FILE_PREVIEW_DEFAULT_WIDTH);
|
||||
const filePreviewCloseTimerRef = useRef<number | null>(null);
|
||||
const pendingFirstRef = useRef<PendingFirstMessage | null>(null);
|
||||
@@ -664,6 +676,7 @@ export function ThreadShell({
|
||||
const viewportRef = useRef<ThreadViewportHandle | null>(null);
|
||||
const activeViewportTurnByChatIdRef = useRef<Map<string, string>>(new Map());
|
||||
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
|
||||
const knownTemporaryChatIdsRef = useRef(new Set<string>());
|
||||
/** Last chatId we associated with the in-memory thread (for cache-on-switch). */
|
||||
const prevChatIdForCacheRef = useRef<string | null>(null);
|
||||
/** Skip one message-cache write right after chatId changes (messages may not match yet). */
|
||||
@@ -678,6 +691,8 @@ export function ThreadShell({
|
||||
const sessionKeyByChatIdRef = useRef<Map<string, string>>(new Map());
|
||||
const currentUiMessagesRef = useRef<UIMessage[] | null>(null);
|
||||
const uiRevisionRef = useRef(0);
|
||||
const showTemporaryChatControl =
|
||||
!hideHeader && !session && !loading && !!onTemporaryChatEnabledChange;
|
||||
|
||||
const initial = useMemo(() => {
|
||||
if (!chatId) return historical;
|
||||
@@ -736,6 +751,18 @@ export function ThreadShell({
|
||||
setSubmittedViewportTurnId(null);
|
||||
}, [historyKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const retained = new Set(temporaryChatIds);
|
||||
for (const chatId of retained) knownTemporaryChatIdsRef.current.add(chatId);
|
||||
for (const cachedChatId of knownTemporaryChatIdsRef.current) {
|
||||
if (!retained.has(cachedChatId)) {
|
||||
messageCacheRef.current.delete(cachedChatId);
|
||||
activeViewportTurnByChatIdRef.current.delete(cachedChatId);
|
||||
knownTemporaryChatIdsRef.current.delete(cachedChatId);
|
||||
}
|
||||
}
|
||||
}, [temporaryChatIds]);
|
||||
|
||||
const handleQuoteSelection = useCallback((text: string) => {
|
||||
setQuotedContext(text);
|
||||
setComposerFocusSignal((value) => value + 1);
|
||||
@@ -838,6 +865,12 @@ export function ThreadShell({
|
||||
() => modelPresetOptionsFromSettings(settings),
|
||||
[settings],
|
||||
);
|
||||
const availableSlashCommands = useMemo(
|
||||
() => temporary
|
||||
? slashCommands.filter(({ command }) => command === "/model" || command === "/stop")
|
||||
: slashCommands,
|
||||
[slashCommands, temporary],
|
||||
);
|
||||
const modelBadge = useMemo(
|
||||
() => toModelBadgeInfo(modelName, settings, activeModelPreset),
|
||||
[activeModelPreset, modelName, settings],
|
||||
@@ -898,7 +931,7 @@ export function ThreadShell({
|
||||
}, [chatId, client]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId || loading) return;
|
||||
if (!historyKey || !chatId || loading) return;
|
||||
const cached = messageCacheRef.current.get(chatId);
|
||||
const pendingCanonicalHydrate = pendingCanonicalHydrateRef.current.get(chatId);
|
||||
const hasNewCanonicalHistory = (
|
||||
@@ -1028,10 +1061,11 @@ export function ThreadShell({
|
||||
historyLineage,
|
||||
historyActiveTurnId,
|
||||
hasPendingToolCalls,
|
||||
historyKey,
|
||||
]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!chatId) return;
|
||||
if (!historyKey || !chatId) return;
|
||||
const commit = pendingCanonicalCommitRef.current.get(chatId);
|
||||
if (!commit) return;
|
||||
if (
|
||||
@@ -1069,17 +1103,17 @@ export function ThreadShell({
|
||||
pendingCanonicalCommitRef.current.delete(chatId);
|
||||
committedHistoryLineageRef.current.set(chatId, historyLineage);
|
||||
completedCanonicalHydrateVersionRef.current.set(chatId, historyVersion);
|
||||
}, [chatId, client, historyLineage, historyVersion, messages, setMessages]);
|
||||
}, [chatId, client, historyKey, historyLineage, historyVersion, messages, setMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId || hasPendingToolCalls) return;
|
||||
if (!historyKey || !chatId || hasPendingToolCalls) return;
|
||||
if (completedCanonicalHydrateVersionRef.current.get(chatId) !== historyVersion) return;
|
||||
completedCanonicalHydrateVersionRef.current.delete(chatId);
|
||||
reconcileTurnComplete();
|
||||
}, [chatId, hasPendingToolCalls, historyVersion, messages, reconcileTurnComplete]);
|
||||
}, [chatId, hasPendingToolCalls, historyKey, historyVersion, messages, reconcileTurnComplete]);
|
||||
|
||||
const refreshCanonicalHistory = useCallback(() => {
|
||||
if (!chatId) return;
|
||||
if (!historyKey || !chatId) return;
|
||||
pendingCanonicalHydrateRef.current.set(chatId, {
|
||||
historyLineage,
|
||||
historyVersion,
|
||||
@@ -1089,10 +1123,10 @@ export function ThreadShell({
|
||||
uiRevision: uiRevisionRef.current,
|
||||
});
|
||||
refreshHistory();
|
||||
}, [chatId, client, historyLineage, historyVersion, refreshHistory]);
|
||||
}, [chatId, client, historyKey, historyLineage, historyVersion, refreshHistory]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chatId) return;
|
||||
if (!historyKey || !chatId) return;
|
||||
return client.onSessionUpdate((updatedChatId, scope) => {
|
||||
if (updatedChatId !== chatId) return;
|
||||
if (scope === "metadata") return;
|
||||
@@ -1101,7 +1135,7 @@ export function ThreadShell({
|
||||
// so keep an active programmatic follow alive across canonical hydration.
|
||||
refreshCanonicalHistory();
|
||||
});
|
||||
}, [chatId, client, refreshCanonicalHistory]);
|
||||
}, [chatId, client, historyKey, refreshCanonicalHistory]);
|
||||
|
||||
const wasPageHiddenRef = useRef(document.visibilityState === "hidden");
|
||||
useEffect(() => {
|
||||
@@ -1112,7 +1146,7 @@ export function ThreadShell({
|
||||
}
|
||||
if (!wasPageHiddenRef.current) return;
|
||||
wasPageHiddenRef.current = false;
|
||||
if (!chatId || client.status !== "open" || loading) return;
|
||||
if (!historyKey || !chatId || client.status !== "open" || loading) return;
|
||||
if (
|
||||
!turnActive
|
||||
&& !hasPendingToolCalls
|
||||
@@ -1129,6 +1163,7 @@ export function ThreadShell({
|
||||
chatId,
|
||||
client,
|
||||
hasPendingToolCalls,
|
||||
historyKey,
|
||||
historyError,
|
||||
loading,
|
||||
refreshCanonicalHistory,
|
||||
@@ -1230,7 +1265,7 @@ export function ThreadShell({
|
||||
setBooting(true);
|
||||
pendingFirstRef.current = { content, images, options: withWorkspaceScope(options) };
|
||||
setPendingFirstTargetChatId(null);
|
||||
const newId = await onCreateChat?.(workspaceScope);
|
||||
const newId = await onCreateChat?.(workspaceScope, content);
|
||||
if (!newId) {
|
||||
pendingFirstRef.current = null;
|
||||
setPendingFirstTargetChatId(null);
|
||||
@@ -1386,7 +1421,7 @@ export function ThreadShell({
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
variant={showHeroComposer ? "hero" : "thread"}
|
||||
slashCommands={slashCommands}
|
||||
slashCommands={availableSlashCommands}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
sessions={mentionSessions}
|
||||
@@ -1396,12 +1431,13 @@ export function ThreadShell({
|
||||
runStartedAt={currentRunStartedAt}
|
||||
goalState={currentGoalState}
|
||||
workspaceScope={workspaceScope}
|
||||
workspaceControlsHidden={temporary}
|
||||
workspaceDefaultScope={workspaceDefaultScope}
|
||||
workspaceControls={workspaceControls}
|
||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||
workspaceError={workspaceError}
|
||||
onWorkspaceScopeChange={onWorkspaceScopeChange}
|
||||
pendingQueueKey={chatId}
|
||||
pendingQueueKey={temporary ? null : chatId}
|
||||
transcriptionProvider={settingsSnapshot?.transcription?.provider}
|
||||
ingressLimits={ingressLimits}
|
||||
quotedContext={quotedContext}
|
||||
@@ -1429,15 +1465,17 @@ export function ThreadShell({
|
||||
fallbackModelName={fallbackModelName}
|
||||
onModelBadgeClick={modelBadge.needsSetup ? onOpenModelSettings : undefined}
|
||||
variant="hero"
|
||||
slashCommands={slashCommands}
|
||||
slashCommands={availableSlashCommands}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
sessions={mentionSessions}
|
||||
skills={skills}
|
||||
surfaceRef={composerSurfaceRef}
|
||||
runStartedAt={currentRunStartedAt}
|
||||
onTranscribeAudio={transcribeAudio}
|
||||
goalState={currentGoalState}
|
||||
workspaceScope={workspaceScope}
|
||||
workspaceControlsHidden={temporary}
|
||||
workspaceDefaultScope={workspaceDefaultScope}
|
||||
workspaceControls={workspaceControls}
|
||||
workspaceScopeDisabled={workspaceScopeDisabled}
|
||||
@@ -1484,6 +1522,11 @@ export function ThreadShell({
|
||||
minimal={!session && !loading}
|
||||
promptNavigatorAction={promptNavigatorAction}
|
||||
sessionInfoAction={sessionInfoAction}
|
||||
temporaryChatEnabled={temporaryChatEnabled}
|
||||
temporaryChatDisabled={booting || turnActive}
|
||||
onTemporaryChatEnabledChange={
|
||||
showTemporaryChatControl ? onTemporaryChatEnabledChange : undefined
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
<FilePreviewAvailabilityProvider
|
||||
@@ -1492,6 +1535,7 @@ export function ThreadShell({
|
||||
<ThreadViewport
|
||||
ref={viewportRef}
|
||||
messages={displayMessages}
|
||||
temporary={temporary}
|
||||
isStreaming={turnActive}
|
||||
emptyState={emptyState}
|
||||
composer={composer}
|
||||
@@ -1502,7 +1546,7 @@ export function ThreadShell({
|
||||
showScrollToBottomButton={!!session}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
slashCommands={slashCommands}
|
||||
slashCommands={availableSlashCommands}
|
||||
forkBoundaryMessageCount={forkBoundaryMessageCount}
|
||||
hasMoreBefore={hasMoreBefore}
|
||||
loadingOlder={loadingOlder}
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface ThreadViewportHandle {
|
||||
|
||||
interface ThreadViewportProps {
|
||||
messages: UIMessage[];
|
||||
temporary?: boolean;
|
||||
isStreaming: boolean;
|
||||
composer: ReactNode;
|
||||
emptyState?: ReactNode;
|
||||
@@ -157,6 +158,7 @@ function readSoftKeyboardInsetBottom(container: HTMLElement | null): number {
|
||||
|
||||
export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportProps>(function ThreadViewport({
|
||||
messages,
|
||||
temporary = false,
|
||||
isStreaming,
|
||||
composer,
|
||||
emptyState,
|
||||
@@ -682,6 +684,7 @@ export const ThreadViewport = forwardRef<ThreadViewportHandle, ThreadViewportPro
|
||||
<div ref={messageContentRef} className="mx-auto w-full max-w-[49.5rem]">
|
||||
<ThreadMessages
|
||||
messages={visibleMessages}
|
||||
temporary={temporary}
|
||||
isStreaming={isStreaming}
|
||||
hiddenUserMessageCount={hiddenUserMessageCount}
|
||||
cliApps={cliApps}
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
|
||||
export function WorkspaceProjectPicker({
|
||||
isHero,
|
||||
compact = false,
|
||||
connected = false,
|
||||
disabled,
|
||||
scope,
|
||||
defaultScope,
|
||||
@@ -44,6 +46,8 @@ export function WorkspaceProjectPicker({
|
||||
onChange,
|
||||
}: {
|
||||
isHero: boolean;
|
||||
compact?: boolean;
|
||||
connected?: boolean;
|
||||
disabled?: boolean;
|
||||
scope: WorkspaceScopePayload | null;
|
||||
defaultScope: WorkspaceScopePayload | null;
|
||||
@@ -74,8 +78,12 @@ export function WorkspaceProjectPicker({
|
||||
}, [currentProjectScope?.project_path, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error && visible) setOpen(true);
|
||||
}, [error, visible]);
|
||||
if (disabled) setOpen(false);
|
||||
}, [disabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error && visible && !disabled) setOpen(true);
|
||||
}, [disabled, error, visible]);
|
||||
|
||||
const applyProjectPath = useCallback(
|
||||
(projectPath: string, projectName?: string) => {
|
||||
@@ -115,7 +123,11 @@ export function WorkspaceProjectPicker({
|
||||
|
||||
if (nativeProjectPicker) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4">
|
||||
<div className={cn(
|
||||
compact
|
||||
? "inline-flex"
|
||||
: "flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4",
|
||||
)}>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled || pickingFolder}
|
||||
@@ -123,16 +135,18 @@ export function WorkspaceProjectPicker({
|
||||
title={currentProjectScope?.project_path}
|
||||
onClick={() => void pickNativeFolder()}
|
||||
className={cn(
|
||||
"inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
||||
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
|
||||
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
||||
currentProjectScope && "text-foreground/82",
|
||||
compact
|
||||
? "thread-composer-action touch-target inline-flex h-8 w-8 items-center justify-center rounded-full border border-transparent"
|
||||
: "inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
||||
"text-[12px] font-medium text-muted-foreground/90 transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
||||
compact ? "hover:bg-muted/65" : "hover:bg-background/70",
|
||||
(connected || currentProjectScope) && "text-primary",
|
||||
)}
|
||||
>
|
||||
<Folder className={cn("h-3.5 w-3.5 shrink-0", currentProjectScope && "text-primary")} />
|
||||
<span className="truncate">{projectLabel}</span>
|
||||
<Folder className={cn("shrink-0", compact ? "h-4 w-4" : "h-3.5 w-3.5")} />
|
||||
<span className={compact ? "sr-only" : "truncate"}>{projectLabel}</span>
|
||||
</button>
|
||||
{pathError || error ? (
|
||||
{!compact && (pathError || error) ? (
|
||||
<span role="alert" className="ml-2 min-w-0 truncate text-[11.5px] font-medium text-destructive">
|
||||
{pathError ?? error}
|
||||
</span>
|
||||
@@ -142,7 +156,11 @@ export function WorkspaceProjectPicker({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4">
|
||||
<div className={cn(
|
||||
compact
|
||||
? "inline-flex"
|
||||
: "flex min-w-0 items-center rounded-b-[28px] bg-muted/45 px-3 py-1.5 dark:bg-white/[0.045] sm:px-4",
|
||||
)}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
@@ -150,15 +168,19 @@ export function WorkspaceProjectPicker({
|
||||
disabled={disabled}
|
||||
aria-label={t("thread.composer.workspace.projectAria")}
|
||||
className={cn(
|
||||
"inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
||||
"text-[12px] font-medium text-muted-foreground/90 transition-colors",
|
||||
"hover:bg-background/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
||||
currentProjectScope && "text-foreground/82",
|
||||
compact
|
||||
? "thread-composer-action touch-target inline-flex h-8 w-8 items-center justify-center rounded-full border border-transparent"
|
||||
: "inline-flex h-7 max-w-full items-center gap-2 rounded-full px-2.5 sm:max-w-[18rem]",
|
||||
"text-[12px] font-medium text-muted-foreground/90 transition-colors hover:text-foreground disabled:pointer-events-none disabled:opacity-55",
|
||||
compact ? "hover:bg-muted/65" : "hover:bg-background/70",
|
||||
(connected || currentProjectScope) && "text-primary",
|
||||
)}
|
||||
>
|
||||
<Folder className={cn("h-3.5 w-3.5 shrink-0", currentProjectScope && "text-primary")} />
|
||||
<span className="truncate">{projectLabel}</span>
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
<Folder className={cn("shrink-0", compact ? "h-4 w-4" : "h-3.5 w-3.5")} />
|
||||
<span className={compact ? "sr-only" : "truncate"}>{projectLabel}</span>
|
||||
{!compact ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
--input: 40 8% 90.5%;
|
||||
--ring: 0 0% 3.9%;
|
||||
--inline-token-highlight: #ef8e30;
|
||||
--temporary-control-active: #ef8e30;
|
||||
--temporary-accent: 24 95% 53%;
|
||||
--temporary-foreground: 17 88% 32%;
|
||||
--temporary-border: 17 88% 40%;
|
||||
--radius: 0.4375rem;
|
||||
--sidebar: 40 8% 96.8%;
|
||||
--sidebar-foreground: 0 0% 3.9%;
|
||||
@@ -67,6 +71,10 @@
|
||||
--input: var(--border);
|
||||
--ring: 0 0% 83.1%;
|
||||
--inline-token-highlight: #ef8e30;
|
||||
--temporary-control-active: #ef8e30;
|
||||
--temporary-accent: 24 95% 53%;
|
||||
--temporary-foreground: 32 98% 73%;
|
||||
--temporary-border: 27 96% 61%;
|
||||
--sidebar: var(--card);
|
||||
--sidebar-foreground: 0 0% 98%;
|
||||
--sidebar-accent: var(--background);
|
||||
@@ -435,6 +443,41 @@
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.composer-workspace-drawer {
|
||||
--composer-workspace-drawer-duration: 220ms;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.composer-workspace-drawer[data-state="open"] {
|
||||
--composer-workspace-drawer-duration: 240ms;
|
||||
|
||||
grid-template-rows: 1fr;
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.composer-workspace-drawer-clip {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.composer-workspace-drawer {
|
||||
transition:
|
||||
grid-template-rows var(--composer-workspace-drawer-duration)
|
||||
cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity var(--composer-workspace-drawer-duration) ease-in-out;
|
||||
}
|
||||
.composer-workspace-drawer-content {
|
||||
transform: translateY(-6px);
|
||||
transition: transform var(--composer-workspace-drawer-duration)
|
||||
cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
.composer-workspace-drawer[data-state="open"] .composer-workspace-drawer-content {
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes run-pulse-dot {
|
||||
0%,
|
||||
100% {
|
||||
|
||||
@@ -1461,6 +1461,8 @@ export function useNanobotStream(
|
||||
return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
|
||||
});
|
||||
suppressStreamUntilTurnEndRef.current = false;
|
||||
setRunStartedAt(null);
|
||||
client.finishRunLocally(chatId);
|
||||
client.sendMessage(chatId, "/stop");
|
||||
}, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
|
||||
|
||||
|
||||
@@ -49,6 +49,13 @@
|
||||
"noMatch": "No pending request matches this code."
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "Temporary chat",
|
||||
"description": "Not saved to history or memory. Reloading, closing, or losing the connection ends these chats. Requests still go to your model provider, and tool actions may leave changes.",
|
||||
"clear": "Clear temporary chat",
|
||||
"sectionTitle": "Temporary chats",
|
||||
"closeAction": "Close temporary chat: {{title}}"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Sidebar navigation",
|
||||
"collapse": "Collapse sidebar",
|
||||
|
||||
@@ -49,6 +49,13 @@
|
||||
"noMatch": "No hay ninguna solicitud pendiente que coincida con este código."
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "Chat temporal",
|
||||
"description": "No se guarda en el historial ni en la memoria. Recargar, cerrar o perder la conexión finaliza estos chats. Las solicitudes siguen llegando al proveedor del modelo y las herramientas pueden dejar cambios.",
|
||||
"clear": "Borrar chat temporal",
|
||||
"sectionTitle": "Chats temporales",
|
||||
"closeAction": "Cerrar chat temporal: {{title}}"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Navegación de la barra lateral",
|
||||
"collapse": "Contraer barra lateral",
|
||||
|
||||
@@ -49,6 +49,13 @@
|
||||
"noMatch": "Aucune demande en attente ne correspond à ce code."
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "Discussion temporaire",
|
||||
"description": "Elle n’est enregistrée ni dans l’historique ni dans la mémoire. Recharger, fermer ou perdre la connexion met fin à ces discussions. Les requêtes sont tout de même envoyées au fournisseur du modèle et les outils peuvent laisser des modifications.",
|
||||
"clear": "Effacer la discussion temporaire",
|
||||
"sectionTitle": "Discussions temporaires",
|
||||
"closeAction": "Fermer la discussion temporaire : {{title}}"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Navigation de la barre latérale",
|
||||
"collapse": "Réduire la barre latérale",
|
||||
|
||||
@@ -49,6 +49,13 @@
|
||||
"noMatch": "Tidak ada permintaan tertunda yang cocok dengan kode ini."
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "Obrolan sementara",
|
||||
"description": "Tidak disimpan ke riwayat atau memori. Memuat ulang, menutup, atau kehilangan koneksi akan mengakhiri obrolan ini. Permintaan tetap dikirim ke penyedia model dan tindakan alat dapat meninggalkan perubahan.",
|
||||
"clear": "Hapus obrolan sementara",
|
||||
"sectionTitle": "Obrolan sementara",
|
||||
"closeAction": "Tutup obrolan sementara: {{title}}"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Navigasi bilah samping",
|
||||
"collapse": "Ciutkan sidebar",
|
||||
|
||||
@@ -49,6 +49,13 @@
|
||||
"noMatch": "このコードに一致する保留中のリクエストはありません。"
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "一時チャット",
|
||||
"description": "履歴やメモリには保存されません。再読み込み、ページを閉じる操作、接続切断で一時チャットは終了します。リクエストは引き続きモデルプロバイダーに送信され、ツール操作による変更は残る場合があります。",
|
||||
"clear": "一時チャットを消去",
|
||||
"sectionTitle": "一時チャット",
|
||||
"closeAction": "一時チャット「{{title}}」を閉じる"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "サイドバーのナビゲーション",
|
||||
"collapse": "サイドバーを閉じる",
|
||||
|
||||
@@ -49,6 +49,13 @@
|
||||
"noMatch": "이 코드와 일치하는 대기 중인 요청이 없습니다."
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "임시 채팅",
|
||||
"description": "기록이나 메모리에 저장되지 않습니다. 새로고침, 페이지 닫기 또는 연결 끊김 시 임시 채팅이 종료됩니다. 요청은 계속 모델 제공업체로 전송되며 도구 작업의 변경 사항은 남을 수 있습니다.",
|
||||
"clear": "임시 채팅 지우기",
|
||||
"sectionTitle": "임시 채팅",
|
||||
"closeAction": "임시 채팅 닫기: {{title}}"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "사이드바 탐색",
|
||||
"collapse": "사이드바 접기",
|
||||
|
||||
@@ -49,6 +49,13 @@
|
||||
"noMatch": "Nenhuma solicitação pendente corresponde a este código."
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "Chat temporário",
|
||||
"description": "Não é salvo no histórico nem na memória. Recarregar, fechar ou perder a conexão encerra estes chats. As solicitações ainda são enviadas ao provedor do modelo, e as ações das ferramentas podem deixar alterações.",
|
||||
"clear": "Limpar chat temporário",
|
||||
"sectionTitle": "Chats temporários",
|
||||
"closeAction": "Fechar chat temporário: {{title}}"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Navegação da barra lateral",
|
||||
"collapse": "Recolher barra lateral",
|
||||
|
||||
@@ -49,6 +49,13 @@
|
||||
"noMatch": "Không có yêu cầu đang chờ nào khớp với mã này."
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "Trò chuyện tạm thời",
|
||||
"description": "Không được lưu vào lịch sử hoặc bộ nhớ. Tải lại, đóng trang hoặc mất kết nối sẽ kết thúc các cuộc trò chuyện này. Yêu cầu vẫn được gửi đến nhà cung cấp mô hình và thao tác công cụ có thể để lại thay đổi.",
|
||||
"clear": "Xóa trò chuyện tạm thời",
|
||||
"sectionTitle": "Trò chuyện tạm thời",
|
||||
"closeAction": "Đóng trò chuyện tạm thời: {{title}}"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "Điều hướng thanh bên",
|
||||
"collapse": "Thu gọn thanh bên",
|
||||
|
||||
@@ -49,6 +49,13 @@
|
||||
"noMatch": "没有待处理请求与此配对码匹配。"
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "临时聊天",
|
||||
"description": "不会保存到历史记录或记忆。刷新、关闭页面或连接中断后,临时聊天会结束。请求仍会发送给模型提供商,工具操作也可能留下更改。",
|
||||
"clear": "清空临时聊天",
|
||||
"sectionTitle": "临时聊天",
|
||||
"closeAction": "关闭临时聊天:{{title}}"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "侧边栏导航",
|
||||
"collapse": "收起侧边栏",
|
||||
|
||||
@@ -49,6 +49,13 @@
|
||||
"noMatch": "沒有待處理請求符合此配對碼。"
|
||||
}
|
||||
},
|
||||
"temporaryChat": {
|
||||
"title": "臨時聊天",
|
||||
"description": "不會儲存至歷史記錄或記憶。重新載入、關閉頁面或連線中斷後,臨時聊天會結束。請求仍會傳送給模型供應商,工具操作也可能留下變更。",
|
||||
"clear": "清空臨時聊天",
|
||||
"sectionTitle": "臨時聊天",
|
||||
"closeAction": "關閉臨時聊天:{{title}}"
|
||||
},
|
||||
"sidebar": {
|
||||
"navigation": "側邊欄導覽",
|
||||
"collapse": "收合側邊欄",
|
||||
|
||||
@@ -108,6 +108,10 @@ interface PendingRequest<T> {
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
}
|
||||
|
||||
interface PendingChatRequest extends PendingRequest<string> {
|
||||
temporary: boolean;
|
||||
}
|
||||
|
||||
const SYSTEM_COMMAND_TURN_PREFIX = "webui-system:";
|
||||
const TURN_REJECTION_DETAILS = new Set([
|
||||
"access_denied",
|
||||
@@ -173,6 +177,8 @@ export class NanobotClient {
|
||||
private static readonly PENDING_INBOUND_MAX = 2000;
|
||||
// chat_ids we've attached to since connect; re-attached after reconnects
|
||||
private knownChats = new Set<string>();
|
||||
/** Temporary chats are connection-owned and intentionally not reattached. */
|
||||
private temporaryChatIds = new Set<string>();
|
||||
/** Wall-clock run strip: updated from ``goal_status`` even with no ``onChat`` subscriber. */
|
||||
private runStartedAtByChatId = new Map<string, number>();
|
||||
/** Per-turn clocks let a rejected newer turn fall back without borrowing its timer. */
|
||||
@@ -194,7 +200,7 @@ export class NanobotClient {
|
||||
private static readonly COMPLETED_TURN_FENCE_MAX = 256;
|
||||
/** Latest ``goal_state`` snapshot per ``chat_id`` (multi-session isolation). */
|
||||
private goalStateByChatId = new Map<string, GoalStateWsPayload>();
|
||||
private pendingNewChat: PendingRequest<string> | null = null;
|
||||
private pendingNewChat: PendingChatRequest | null = null;
|
||||
private pendingTranscriptions = new Map<string, PendingRequest<string>>();
|
||||
private pendingSystemCommands = new Map<string, PendingRequest<void>>();
|
||||
// Frames queued while the socket is not yet OPEN
|
||||
@@ -282,6 +288,16 @@ export class NanobotClient {
|
||||
return v === undefined ? null : v;
|
||||
}
|
||||
|
||||
/** Clear the optimistic run state immediately after the user stops a turn. */
|
||||
finishRunLocally(chatId: string): void {
|
||||
const unsettled = [...(this.unsettledRunTurnIdsByChatId.get(chatId) ?? [])];
|
||||
for (const turnId of unsettled) this.settleRunTurn(chatId, turnId);
|
||||
this.latestRunTurnIdByChatId.delete(chatId);
|
||||
if (this.runStartedAtByChatId.delete(chatId)) {
|
||||
this.emitRunStatus(chatId, null);
|
||||
}
|
||||
}
|
||||
|
||||
/** Refresh transport policy after bootstrap token renewal. */
|
||||
updateMaxFrameBytes(maxFrameBytes?: number): void {
|
||||
this.maxFrameBytes = this.normalizeMaxFrameBytes(maxFrameBytes);
|
||||
@@ -725,9 +741,18 @@ export class NanobotClient {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
this.clearTemporaryChats();
|
||||
this.setStatus("closed");
|
||||
}
|
||||
|
||||
discardTemporaryChat(chatId: string): void {
|
||||
if (!this.temporaryChatIds.has(chatId)) return;
|
||||
if (this.socket?.readyState === WS_OPEN) {
|
||||
this.rawSend({ type: "discard_temporary_chat", chat_id: chatId });
|
||||
}
|
||||
this.forgetTemporaryChat(chatId);
|
||||
}
|
||||
|
||||
/** Ask the server to provision a new chat_id; resolves with the assigned id. */
|
||||
newChat(timeoutMs: number = 5_000, workspaceScope?: WorkspaceScopePayload | null): Promise<string> {
|
||||
if (this.pendingNewChat) {
|
||||
@@ -738,7 +763,7 @@ export class NanobotClient {
|
||||
this.pendingNewChat = null;
|
||||
reject(new Error("newChat timed out"));
|
||||
}, timeoutMs);
|
||||
this.pendingNewChat = { resolve, reject, timer };
|
||||
this.pendingNewChat = { resolve, reject, timer, temporary: false };
|
||||
this.queueSend({
|
||||
type: "new_chat",
|
||||
...(workspaceScope ? { workspace_scope: workspaceScope } : {}),
|
||||
@@ -746,6 +771,21 @@ export class NanobotClient {
|
||||
});
|
||||
}
|
||||
|
||||
/** Ask the WebUI gateway to create a connection-owned non-persistent chat. */
|
||||
newTemporaryChat(timeoutMs: number = 5_000): Promise<string> {
|
||||
if (this.pendingNewChat) {
|
||||
return Promise.reject(new Error("newChat already in flight"));
|
||||
}
|
||||
return new Promise<string>((resolve, reject) => {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingNewChat = null;
|
||||
reject(new Error("newTemporaryChat timed out"));
|
||||
}, timeoutMs);
|
||||
this.pendingNewChat = { resolve, reject, timer, temporary: true };
|
||||
this.queueSend({ type: "new_temporary_chat" });
|
||||
});
|
||||
}
|
||||
|
||||
transcribeAudio(
|
||||
dataUrl: string,
|
||||
options?: { durationMs?: number; timeoutMs?: number },
|
||||
@@ -782,7 +822,7 @@ export class NanobotClient {
|
||||
this.pendingNewChat = null;
|
||||
reject(new Error("forkChat timed out"));
|
||||
}, timeoutMs);
|
||||
this.pendingNewChat = { resolve, reject, timer };
|
||||
this.pendingNewChat = { resolve, reject, timer, temporary: false };
|
||||
this.queueSend({
|
||||
type: "fork_chat",
|
||||
source_chat_id: sourceChatId,
|
||||
@@ -793,6 +833,7 @@ export class NanobotClient {
|
||||
}
|
||||
|
||||
attach(chatId: string): void {
|
||||
if (this.temporaryChatIds.has(chatId)) return;
|
||||
this.knownChats.add(chatId);
|
||||
if (this.socket?.readyState === WS_OPEN) {
|
||||
this.queueSend({ type: "attach", chat_id: chatId });
|
||||
@@ -814,7 +855,8 @@ export class NanobotClient {
|
||||
startsNewRun?: boolean;
|
||||
},
|
||||
): void {
|
||||
this.knownChats.add(chatId);
|
||||
const temporary = this.temporaryChatIds.has(chatId);
|
||||
if (!temporary) this.knownChats.add(chatId);
|
||||
const frame: Outbound = {
|
||||
type: "message",
|
||||
chat_id: chatId,
|
||||
@@ -863,6 +905,7 @@ export class NanobotClient {
|
||||
}
|
||||
|
||||
setWorkspaceScope(chatId: string, workspaceScope: WorkspaceScopePayload): void {
|
||||
if (this.temporaryChatIds.has(chatId)) return;
|
||||
this.knownChats.add(chatId);
|
||||
this.queueSend({
|
||||
type: "set_workspace_scope",
|
||||
@@ -987,8 +1030,15 @@ export class NanobotClient {
|
||||
}
|
||||
|
||||
if (parsed.event === "attached") {
|
||||
this.knownChats.add(parsed.chat_id);
|
||||
if (this.pendingNewChat) {
|
||||
if (parsed.temporary === true) {
|
||||
this.temporaryChatIds.add(parsed.chat_id);
|
||||
} else {
|
||||
this.knownChats.add(parsed.chat_id);
|
||||
}
|
||||
if (
|
||||
this.pendingNewChat
|
||||
&& this.pendingNewChat.temporary === (parsed.temporary === true)
|
||||
) {
|
||||
clearTimeout(this.pendingNewChat.timer);
|
||||
this.pendingNewChat.resolve(parsed.chat_id);
|
||||
this.pendingNewChat = null;
|
||||
@@ -1094,6 +1144,7 @@ export class NanobotClient {
|
||||
|
||||
private handleClose(event?: { code?: number }): void {
|
||||
this.socket = null;
|
||||
this.clearTemporaryChats();
|
||||
if (this.pendingNewChat) {
|
||||
clearTimeout(this.pendingNewChat.timer);
|
||||
this.pendingNewChat.reject(new Error("socket closed"));
|
||||
@@ -1240,6 +1291,40 @@ export class NanobotClient {
|
||||
}
|
||||
}
|
||||
|
||||
private clearTemporaryChats(): void {
|
||||
for (const chatId of [...this.temporaryChatIds]) {
|
||||
this.forgetTemporaryChat(chatId);
|
||||
}
|
||||
}
|
||||
|
||||
private forgetTemporaryChat(chatId: string): void {
|
||||
this.temporaryChatIds.delete(chatId);
|
||||
this.knownChats.delete(chatId);
|
||||
this.chatHandlers.delete(chatId);
|
||||
this.pendingInboundByChat.delete(chatId);
|
||||
const wasRunning = this.runStartedAtByChatId.delete(chatId);
|
||||
this.runGenerationByChatId.delete(chatId);
|
||||
this.latestRunTurnIdByChatId.delete(chatId);
|
||||
this.unsettledRunTurnIdsByChatId.delete(chatId);
|
||||
this.canonicalCompletedTurnIdsByChatId.delete(chatId);
|
||||
this.goalStateByChatId.delete(chatId);
|
||||
for (const key of [...this.runStartedAtByTurnKey.keys()]) {
|
||||
if (key.startsWith(`${chatId}\u0000`)) this.runStartedAtByTurnKey.delete(key);
|
||||
}
|
||||
for (const [key, pending] of [...this.pendingMessageSends]) {
|
||||
if (pending.chatId !== chatId) continue;
|
||||
this.pendingMessageSends.delete(key);
|
||||
this.socketPendingMessageSendKeys.delete(key);
|
||||
}
|
||||
this.sendQueue = this.sendQueue.filter((frame) => (
|
||||
!("chat_id" in frame) || frame.chat_id !== chatId
|
||||
));
|
||||
if (this.lastSocketMessageSendKey?.startsWith(`${chatId}\u0000`)) {
|
||||
this.lastSocketMessageSendKey = null;
|
||||
}
|
||||
if (wasRunning) this.emitRunStatus(chatId, null);
|
||||
}
|
||||
|
||||
private frameFitsTransport(frame: Outbound): boolean {
|
||||
if (this.maxFrameBytes === undefined) return true;
|
||||
return new TextEncoder().encode(JSON.stringify(frame)).byteLength <= this.maxFrameBytes;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { ChatSummary } from "./types";
|
||||
|
||||
const WEBSOCKET_SESSION_KEY_PREFIX = "websocket:";
|
||||
|
||||
export function deriveTemporaryChatTitle(
|
||||
firstMessage: string | undefined,
|
||||
fallback: string,
|
||||
): string {
|
||||
const oneLine = firstMessage?.replace(/\s+/g, " ").trim() ?? "";
|
||||
if (!oneLine) return fallback;
|
||||
return oneLine.length > 60 ? `${oneLine.slice(0, 57)}…` : oneLine;
|
||||
}
|
||||
|
||||
export function createTemporaryChatSession(chatId: string): ChatSummary {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
key: `${WEBSOCKET_SESSION_KEY_PREFIX}${chatId}`,
|
||||
channel: "websocket",
|
||||
chatId,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
preview: "",
|
||||
};
|
||||
}
|
||||
@@ -1162,7 +1162,7 @@ export interface InboundTurnMetadata {
|
||||
|
||||
export type InboundEvent =
|
||||
| { event: "ready"; chat_id: string; client_id: string }
|
||||
| { event: "attached"; chat_id: string }
|
||||
| { event: "attached"; chat_id: string; temporary?: boolean }
|
||||
| { event: "message_accepted"; chat_id: string; turn_id: string }
|
||||
| ({
|
||||
event: "message";
|
||||
@@ -1338,9 +1338,11 @@ export interface FilePreviewPayload {
|
||||
|
||||
export type Outbound =
|
||||
| { type: "new_chat"; workspace_scope?: WorkspaceScopePayload }
|
||||
| { type: "new_temporary_chat" }
|
||||
| { type: "fork_chat"; source_chat_id: string; before_user_index: number; title?: string }
|
||||
| { type: "attach"; chat_id: string }
|
||||
| { type: "set_sidebar_state"; state: SidebarStatePayload }
|
||||
| { 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 }
|
||||
| {
|
||||
|
||||
@@ -3,7 +3,12 @@ import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import i18n from "@/i18n";
|
||||
import type { ChatSummary, SessionAutomationJob } from "@/lib/types";
|
||||
import type {
|
||||
ChatSummary,
|
||||
ConnectionStatus,
|
||||
SessionAutomationJob,
|
||||
WorkspaceScopePayload,
|
||||
} from "@/lib/types";
|
||||
|
||||
const connectSpy = vi.fn();
|
||||
const refreshSpy = vi.fn();
|
||||
@@ -14,8 +19,16 @@ const toggleThemeSpy = vi.fn();
|
||||
const updateUrlSpy = vi.fn();
|
||||
const attachSpy = vi.fn();
|
||||
const setSidebarStateSpy = vi.fn();
|
||||
const discardTemporaryChatSpy = vi.fn();
|
||||
const newTemporaryChatSpy = vi.fn<() => Promise<string>>();
|
||||
const sendMessageSpy = vi.fn();
|
||||
const statusHandlers = new Set<(status: ConnectionStatus) => void>();
|
||||
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
|
||||
const sessionUpdateHandlers = new Set<(chatId: string, scope?: string) => void>();
|
||||
const sessionUpdateHandlers = new Set<(
|
||||
chatId: string,
|
||||
scope?: string,
|
||||
workspaceScope?: WorkspaceScopePayload,
|
||||
) => void>();
|
||||
let mockSessions: ChatSummary[] = [];
|
||||
const HERO_GREETING_PATTERN =
|
||||
/What should we work on\?|Where should we start\?|What are we building today\?|What should we tackle together\?/;
|
||||
@@ -197,16 +210,24 @@ vi.mock("@/lib/bootstrap", () => ({
|
||||
clearSavedSecret: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/nanobot-client", () => {
|
||||
vi.mock("@/lib/nanobot-client", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@/lib/nanobot-client")>();
|
||||
class MockClient {
|
||||
status = "idle" as const;
|
||||
defaultChatId: string | null = null;
|
||||
connect = connectSpy;
|
||||
onStatus = () => () => {};
|
||||
onStatus = (handler: (status: ConnectionStatus) => void) => {
|
||||
statusHandlers.add(handler);
|
||||
return () => statusHandlers.delete(handler);
|
||||
};
|
||||
onRuntimeModelUpdate = () => () => {};
|
||||
onError = () => () => {};
|
||||
onChat = () => () => {};
|
||||
onSessionUpdate = (handler: (chatId: string, scope?: string) => void) => {
|
||||
onSessionUpdate = (handler: (
|
||||
chatId: string,
|
||||
scope?: string,
|
||||
workspaceScope?: WorkspaceScopePayload,
|
||||
) => void) => {
|
||||
sessionUpdateHandlers.add(handler);
|
||||
return () => sessionUpdateHandlers.delete(handler);
|
||||
};
|
||||
@@ -216,16 +237,18 @@ vi.mock("@/lib/nanobot-client", () => {
|
||||
};
|
||||
getRunStartedAt = () => null;
|
||||
getGoalState = () => undefined;
|
||||
sendMessage = vi.fn();
|
||||
sendMessage = sendMessageSpy;
|
||||
newChat = vi.fn();
|
||||
newTemporaryChat = newTemporaryChatSpy;
|
||||
attach = attachSpy;
|
||||
setSidebarState = setSidebarStateSpy;
|
||||
discardTemporaryChat = discardTemporaryChatSpy;
|
||||
close = vi.fn();
|
||||
updateUrl = updateUrlSpy;
|
||||
updateMaxFrameBytes = vi.fn();
|
||||
}
|
||||
|
||||
return { NanobotClient: MockClient };
|
||||
return { ...actual, NanobotClient: MockClient };
|
||||
});
|
||||
|
||||
import {
|
||||
@@ -248,6 +271,13 @@ describe("App layout", () => {
|
||||
toggleThemeSpy.mockReset();
|
||||
attachSpy.mockReset();
|
||||
setSidebarStateSpy.mockReset();
|
||||
discardTemporaryChatSpy.mockReset();
|
||||
let temporaryChatCounter = 0;
|
||||
newTemporaryChatSpy.mockImplementation(async () => (
|
||||
`00000000-0000-4000-8000-${String(++temporaryChatCounter).padStart(12, "0")}`
|
||||
));
|
||||
sendMessageSpy.mockReset();
|
||||
statusHandlers.clear();
|
||||
runStatusHandlers.clear();
|
||||
sessionUpdateHandlers.clear();
|
||||
window.history.replaceState(null, "", "/");
|
||||
@@ -384,6 +414,233 @@ describe("App layout", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("creates a new temporary chat from the hero each time", async () => {
|
||||
const { unmount } = render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
expect(within(sidebar).queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
|
||||
const firstToggle = screen.getByRole("button", { name: "Temporary chat" });
|
||||
expect(firstToggle).toHaveAttribute("aria-pressed", "false");
|
||||
fireEvent.click(firstToggle);
|
||||
expect(firstToggle).toHaveAttribute("aria-pressed", "true");
|
||||
expect(window.location.hash).toBe("");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "first private message" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/));
|
||||
const firstHash = window.location.hash;
|
||||
expect(firstHash).toMatch(/^#\/temporary\/[0-9a-f-]+$/);
|
||||
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
|
||||
expect(createChatSpy).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "New topic" }));
|
||||
expect(discardTemporaryChatSpy).not.toHaveBeenCalled();
|
||||
const secondToggle = screen.getByRole("button", { name: "Temporary chat" });
|
||||
expect(secondToggle).toHaveAttribute("aria-pressed", "false");
|
||||
|
||||
fireEvent.click(secondToggle);
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "second private message" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/));
|
||||
const secondHash = window.location.hash;
|
||||
expect(secondHash).toMatch(/^#\/temporary\/[0-9a-f-]+$/);
|
||||
expect(secondHash).not.toBe(firstHash);
|
||||
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
|
||||
expect(discardTemporaryChatSpy).not.toHaveBeenCalled();
|
||||
|
||||
expect(within(sidebar).getByText("Temporary chats")).toBeInTheDocument();
|
||||
expect(within(sidebar).getByRole("button", {
|
||||
name: "first private message",
|
||||
})).toBeInTheDocument();
|
||||
expect(within(sidebar).getByRole("button", {
|
||||
name: "second private message",
|
||||
})).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(sidebar).getByRole("button", {
|
||||
name: "first private message",
|
||||
}));
|
||||
await waitFor(() => expect(window.location.hash).toBe(firstHash));
|
||||
expect(within(screen.getByTestId("thread-header")).getByText(
|
||||
"first private message",
|
||||
)).toBeInTheDocument();
|
||||
await waitFor(() => expect(document.title).toBe("first private message · nanobot"));
|
||||
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(sidebar).getByRole("button", {
|
||||
name: "Close temporary chat: first private message",
|
||||
}));
|
||||
await waitFor(() => expect(window.location.hash).toBe(secondHash));
|
||||
expect(within(sidebar).queryByRole("button", {
|
||||
name: "first private message",
|
||||
})).not.toBeInTheDocument();
|
||||
expect(within(sidebar).getByRole("button", {
|
||||
name: "second private message",
|
||||
})).toBeInTheDocument();
|
||||
expect(discardTemporaryChatSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
unmount();
|
||||
await waitFor(() => expect(discardTemporaryChatSpy).toHaveBeenCalledTimes(2));
|
||||
const discardedChatIds = discardTemporaryChatSpy.mock.calls.map(([chatId]) => chatId);
|
||||
expect(new Set(discardedChatIds).size).toBe(2);
|
||||
expect(discardedChatIds).toEqual([
|
||||
"00000000-0000-4000-8000-000000000001",
|
||||
"00000000-0000-4000-8000-000000000002",
|
||||
]);
|
||||
});
|
||||
|
||||
it("shows the temporary-chat control only on the new-topic hero", async () => {
|
||||
mockSessions = [{
|
||||
key: "websocket:existing-chat",
|
||||
channel: "websocket",
|
||||
chatId: "existing-chat",
|
||||
createdAt: "2026-08-06T10:00:00Z",
|
||||
updatedAt: "2026-08-06T10:00:00Z",
|
||||
preview: "Existing topic",
|
||||
}];
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
const heroHeader = screen.getByTestId("thread-header");
|
||||
const heroTemporaryToggle = within(heroHeader).getByRole("button", {
|
||||
name: "Temporary chat",
|
||||
});
|
||||
const themeToggle = within(heroHeader).getByRole("button", {
|
||||
name: "Toggle theme from header",
|
||||
});
|
||||
expect(within(sidebar).queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
|
||||
expect(within(screen.getByTestId("thread-composer-motion")).queryByRole("button", {
|
||||
name: "Temporary chat",
|
||||
})).not.toBeInTheDocument();
|
||||
expect(heroTemporaryToggle.compareDocumentPosition(themeToggle)
|
||||
& Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
|
||||
fireEvent.click(within(sidebar).getByText("Existing topic"));
|
||||
expect(window.location.hash).toBe("#/chat/websocket%3Aexisting-chat");
|
||||
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "New topic" }));
|
||||
const temporaryToggle = screen.getByRole("button", { name: "Temporary chat" });
|
||||
expect(temporaryToggle).toHaveClass("h-8", "w-8", "rounded-full");
|
||||
expect(within(temporaryToggle).queryByText("Temporary chat")).not.toBeInTheDocument();
|
||||
fireEvent.click(temporaryToggle);
|
||||
expect(temporaryToggle).toHaveAttribute("aria-pressed", "true");
|
||||
expect(temporaryToggle).toHaveClass("bg-transparent", "shadow-none", "hover:bg-transparent");
|
||||
expect(within(temporaryToggle).getByTestId("temporary-chat-icon")).toHaveClass(
|
||||
"motion-safe:duration-150",
|
||||
"text-[var(--temporary-control-active)]",
|
||||
);
|
||||
expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("temporary-chat-outline")).not.toBeInTheDocument();
|
||||
fireEvent.click(temporaryToggle);
|
||||
expect(temporaryToggle).toHaveAttribute("aria-pressed", "false");
|
||||
expect(within(temporaryToggle).getByTestId("temporary-chat-icon")).toHaveClass(
|
||||
"motion-safe:duration-75",
|
||||
"text-current",
|
||||
);
|
||||
fireEvent.click(temporaryToggle);
|
||||
expect(window.location.hash).toBe("#/new");
|
||||
expect(temporaryToggle).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "start temporary chat" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/));
|
||||
|
||||
expect(screen.queryByText("Not saved")).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Clear temporary chat" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Temporary chat" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows leaving a page with temporary chats without blocking", async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
fireEvent.click(screen.getByRole("button", { name: "Temporary chat" }));
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "do not lose this" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/));
|
||||
|
||||
const beforeUnload = new Event("beforeunload", { cancelable: true });
|
||||
act(() => window.dispatchEvent(beforeUnload));
|
||||
expect(beforeUnload.defaultPrevented).toBe(false);
|
||||
});
|
||||
|
||||
it("ends temporary chats quietly after a connection interruption", async () => {
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
act(() => {
|
||||
statusHandlers.forEach((handler) => handler("open"));
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Temporary chat" }));
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "connection-sensitive message" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expect(window.location.hash).toMatch(/^#\/temporary\/[0-9a-f-]+$/));
|
||||
|
||||
act(() => {
|
||||
statusHandlers.forEach((handler) => handler("reconnecting"));
|
||||
});
|
||||
|
||||
await waitFor(() => expect(window.location.hash).toBe("#/new"));
|
||||
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("connection-sensitive message")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uses the restricted default scope without offering project selection", async () => {
|
||||
mockFetchRoutes({
|
||||
"/api/workspaces": {
|
||||
schema_version: 1,
|
||||
default_access_mode: "full",
|
||||
default_scope: {
|
||||
project_path: "/tmp/workspace",
|
||||
project_name: "workspace",
|
||||
access_mode: "full",
|
||||
restrict_to_workspace: false,
|
||||
},
|
||||
controls: { can_change_project: true, can_use_full_access: true },
|
||||
},
|
||||
});
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
expect(await screen.findByRole("button", { name: "Choose project" })).toBeInTheDocument();
|
||||
act(() => {
|
||||
sessionUpdateHandlers.forEach((handler) => handler("selected-chat", "metadata", {
|
||||
project_path: "/tmp/selected-project",
|
||||
project_name: "selected-project",
|
||||
access_mode: "full",
|
||||
restrict_to_workspace: false,
|
||||
}));
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Temporary chat" }));
|
||||
|
||||
expect(screen.queryByRole("button", { name: "Choose project" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByText("Full Access")).not.toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "temporary project check" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expect(sendMessageSpy).toHaveBeenCalled());
|
||||
const options = sendMessageSpy.mock.calls.at(-1)?.[3];
|
||||
expect(options?.workspaceScope).toMatchObject({
|
||||
project_path: "/tmp/workspace",
|
||||
access_mode: "restricted",
|
||||
restrict_to_workspace: true,
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -152,6 +152,39 @@ describe("ChatList", () => {
|
||||
expect(text.indexOf("Charlie")).toBeLessThan(text.indexOf("Alpha"));
|
||||
});
|
||||
|
||||
it("shows temporary chats separately and lets the user reopen or close them", async () => {
|
||||
const temporarySession = session({
|
||||
key: "temporary:temporary-one",
|
||||
chatId: "temporary-one",
|
||||
preview: "hi",
|
||||
});
|
||||
const onSelect = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
||||
render(
|
||||
<ChatList
|
||||
sessions={[]}
|
||||
temporarySessions={[temporarySession]}
|
||||
activeKey={null}
|
||||
onSelect={onSelect}
|
||||
onCloseTemporaryChat={onClose}
|
||||
onRequestDelete={vi.fn()}
|
||||
onTogglePin={vi.fn()}
|
||||
onRequestRename={vi.fn()}
|
||||
onToggleArchive={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const section = screen.getByRole("region", { name: "Temporary chats" });
|
||||
fireEvent.click(within(section).getByRole("button", { name: "hi" }));
|
||||
expect(onSelect).toHaveBeenCalledWith("temporary:temporary-one");
|
||||
|
||||
fireEvent.click(within(section).getByRole("button", {
|
||||
name: "Close temporary chat: hi",
|
||||
}));
|
||||
expect(onClose).toHaveBeenCalledWith("temporary:temporary-one");
|
||||
});
|
||||
|
||||
it("orders chats by latest session activity by default", () => {
|
||||
const sessions = [
|
||||
session({
|
||||
|
||||
@@ -113,6 +113,25 @@ describe("MessageBubble", () => {
|
||||
expect(screen.queryByRole("button", { name: "Fork" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("outlines temporary-chat user messages with a short dashed border", () => {
|
||||
const message: UIMessage = {
|
||||
id: "u-temporary",
|
||||
role: "user",
|
||||
content: "private question",
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
|
||||
const { rerender } = render(<MessageBubble message={message} temporary />);
|
||||
const bubble = screen.getByText("private question");
|
||||
|
||||
expect(bubble).toHaveAttribute("data-temporary-message", "true");
|
||||
expect(bubble).toHaveClass("border-dashed", "border-muted-foreground/40", "bg-transparent");
|
||||
|
||||
rerender(<MessageBubble message={message} />);
|
||||
expect(bubble).not.toHaveClass("border-dashed");
|
||||
expect(bubble).toHaveClass("bg-secondary/70");
|
||||
});
|
||||
|
||||
it("does not replay an entrance animation when persisted messages mount", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
|
||||
@@ -71,6 +71,116 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("NanobotClient", () => {
|
||||
it("keeps temporary chats out of attachment and reconnect state", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const chatId = "temp-server-id";
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
const creation = client.newTemporaryChat();
|
||||
expect(JSON.parse(lastSocket().sent.at(-1) as string)).toEqual({
|
||||
type: "new_temporary_chat",
|
||||
});
|
||||
lastSocket().fakeMessage({ event: "attached", chat_id: chatId, temporary: true });
|
||||
await expect(creation).resolves.toBe(chatId);
|
||||
lastSocket().sent = [];
|
||||
client.onChat(chatId, vi.fn());
|
||||
client.sendMessage(chatId, "hello", undefined, { turnId: "turn-1" });
|
||||
|
||||
expect(lastSocket().sent.map((raw) => JSON.parse(raw))).toEqual([
|
||||
{
|
||||
type: "message",
|
||||
chat_id: chatId,
|
||||
content: "hello",
|
||||
turn_id: "turn-1",
|
||||
webui: true,
|
||||
},
|
||||
]);
|
||||
|
||||
client.discardTemporaryChat(chatId);
|
||||
expect(JSON.parse(lastSocket().sent.at(-1) as string)).toEqual({
|
||||
type: "discard_temporary_chat",
|
||||
chat_id: chatId,
|
||||
});
|
||||
});
|
||||
|
||||
it("waits for the temporary attachment when creating a temporary chat", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
|
||||
const creation = client.newTemporaryChat();
|
||||
let resolved = false;
|
||||
void creation.then(() => { resolved = true; });
|
||||
lastSocket().fakeMessage({ event: "attached", chat_id: "ordinary-chat" });
|
||||
await Promise.resolve();
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
lastSocket().fakeMessage({
|
||||
event: "attached",
|
||||
chat_id: "server-temporary-chat",
|
||||
temporary: true,
|
||||
});
|
||||
await expect(creation).resolves.toBe("server-temporary-chat");
|
||||
});
|
||||
|
||||
it("forgets every temporary chat when the socket drops", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: true,
|
||||
maxBackoffMs: 1,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const firstHandler = vi.fn();
|
||||
const secondHandler = vi.fn();
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
const firstCreation = client.newTemporaryChat();
|
||||
lastSocket().fakeMessage({
|
||||
event: "attached",
|
||||
chat_id: "temp-drop-a",
|
||||
temporary: true,
|
||||
});
|
||||
await firstCreation;
|
||||
const secondCreation = client.newTemporaryChat();
|
||||
lastSocket().fakeMessage({
|
||||
event: "attached",
|
||||
chat_id: "temp-drop-b",
|
||||
temporary: true,
|
||||
});
|
||||
await secondCreation;
|
||||
lastSocket().sent = [];
|
||||
client.onChat("temp-drop-a", firstHandler);
|
||||
client.onChat("temp-drop-b", secondHandler);
|
||||
firstHandler.mockClear();
|
||||
secondHandler.mockClear();
|
||||
lastSocket().close();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
lastSocket().fakeOpen();
|
||||
lastSocket().fakeMessage({
|
||||
event: "message",
|
||||
chat_id: "temp-drop-a",
|
||||
text: "stale first chat",
|
||||
});
|
||||
lastSocket().fakeMessage({
|
||||
event: "message",
|
||||
chat_id: "temp-drop-b",
|
||||
text: "stale second chat",
|
||||
});
|
||||
|
||||
expect(lastSocket().sent).toEqual([]);
|
||||
expect(firstHandler).not.toHaveBeenCalled();
|
||||
expect(secondHandler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("routes events to the matching chat handler", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
@@ -214,6 +324,31 @@ describe("NanobotClient", () => {
|
||||
expect(client.getRunStartedAt("chat-strip")).toBeNull();
|
||||
});
|
||||
|
||||
it("clears the local run strip immediately when a stop is requested", () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
reconnect: false,
|
||||
socketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
|
||||
});
|
||||
const handler = vi.fn();
|
||||
client.onRunStatus(handler);
|
||||
client.connect();
|
||||
lastSocket().fakeOpen();
|
||||
lastSocket().fakeMessage({
|
||||
event: "goal_status",
|
||||
chat_id: "chat-stop",
|
||||
status: "running",
|
||||
started_at: 12_345,
|
||||
turn_id: "turn-stop",
|
||||
});
|
||||
|
||||
client.finishRunLocally("chat-stop");
|
||||
|
||||
expect(client.getRunStartedAt("chat-stop")).toBeNull();
|
||||
expect(client.hasUnsettledRun("chat-stop")).toBe(false);
|
||||
expect(handler).toHaveBeenLastCalledWith("chat-stop", null);
|
||||
});
|
||||
|
||||
it("clears stale run strip when reconnecting after a dropped socket", async () => {
|
||||
const client = new NanobotClient({
|
||||
url: "ws://test",
|
||||
|
||||
@@ -1070,6 +1070,55 @@ describe("ThreadComposer", () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it("slides project controls closed without offering a compact replacement", () => {
|
||||
const defaultScope = {
|
||||
project_path: "/Users/test/.nanobot/workspace",
|
||||
project_name: "workspace",
|
||||
access_mode: "full" as const,
|
||||
restrict_to_workspace: false,
|
||||
};
|
||||
const composer = (workspaceControlsHidden: boolean) => (
|
||||
<ThreadComposer
|
||||
onSend={vi.fn()}
|
||||
placeholder="Ask anything..."
|
||||
variant="hero"
|
||||
workspaceControlsHidden={workspaceControlsHidden}
|
||||
workspaceScope={defaultScope}
|
||||
workspaceDefaultScope={defaultScope}
|
||||
workspaceControls={{ can_change_project: true, can_use_full_access: true }}
|
||||
onWorkspaceScopeChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
const { container, rerender } = render(composer(false));
|
||||
const drawer = container.querySelector("[data-composer-workspace-drawer]");
|
||||
|
||||
expect(drawer).toHaveAttribute("data-state", "open");
|
||||
expect(drawer).not.toHaveAttribute("aria-hidden");
|
||||
expect(container.querySelector("[data-composer-workspace-compact]")).not.toBeInTheDocument();
|
||||
|
||||
rerender(composer(true));
|
||||
|
||||
expect(container.querySelector("[data-composer-workspace-drawer]")).toBe(drawer);
|
||||
expect(drawer).toHaveAttribute("data-state", "closed");
|
||||
expect(drawer).toHaveAttribute("aria-hidden", "true");
|
||||
expect(within(drawer as HTMLElement).getByRole("button", {
|
||||
hidden: true,
|
||||
name: "Choose project",
|
||||
})).toBeDisabled();
|
||||
expect(screen.queryByRole("button", { name: "Choose project" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", {
|
||||
name: "Workspace access mode: Full Access",
|
||||
})).not.toBeInTheDocument();
|
||||
|
||||
rerender(composer(false));
|
||||
|
||||
expect(container.querySelector("[data-composer-workspace-drawer]")).toBe(drawer);
|
||||
expect(drawer).toHaveAttribute("data-state", "open");
|
||||
expect(within(drawer as HTMLElement).getByRole("button", {
|
||||
name: "Choose project",
|
||||
})).toBeEnabled();
|
||||
});
|
||||
|
||||
it("uses the native folder picker for project selection on native host", async () => {
|
||||
const onWorkspaceScopeChange = vi.fn();
|
||||
const pickFolder = vi.fn().mockResolvedValue("/Users/test/native-project");
|
||||
@@ -2888,4 +2937,48 @@ describe("ThreadComposer", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps temporary chat guidance in memory only", async () => {
|
||||
const onSend = vi.fn();
|
||||
const view = render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
onStop={vi.fn()}
|
||||
isStreaming
|
||||
pendingQueueKey={null}
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, { target: { value: "do not persist this" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
|
||||
expect(await screen.findByText("do not persist this")).toBeInTheDocument();
|
||||
expect(
|
||||
window.localStorage.getItem(
|
||||
"nanobot.webui.composerQueuedGuidance.v1:temporary-private",
|
||||
),
|
||||
).toBeNull();
|
||||
|
||||
view.unmount();
|
||||
render(
|
||||
<ThreadComposer
|
||||
onSend={onSend}
|
||||
onStop={vi.fn()}
|
||||
isStreaming
|
||||
pendingQueueKey={null}
|
||||
placeholder="Type your message..."
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("do not persist this")).not.toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
window.localStorage.getItem(
|
||||
"nanobot.webui.composerQueuedGuidance.v1:temporary-private",
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -107,6 +107,10 @@ function makeClient() {
|
||||
};
|
||||
},
|
||||
getRunStartedAt: (chatId: string) => runStartedAtByChatId.get(chatId) ?? null,
|
||||
finishRunLocally: vi.fn((chatId: string) => {
|
||||
runStartedAtByChatId.delete(chatId);
|
||||
latestRunTurnIdByChatId.delete(chatId);
|
||||
}),
|
||||
hasUnsettledRun: () => false,
|
||||
getRunGeneration: (chatId: string) => runGenerationByChatId.get(chatId) ?? 0,
|
||||
canReconcileCanonicalCompletion,
|
||||
@@ -848,6 +852,48 @@ describe("ThreadShell", () => {
|
||||
expect(screen.getByText("persist me across tabs")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps temporary messages across navigation and drops them after clear", async () => {
|
||||
const client = makeClient();
|
||||
const view = (
|
||||
chatId: string,
|
||||
temporary: boolean,
|
||||
temporaryChatIds: readonly string[],
|
||||
) => wrap(
|
||||
client,
|
||||
<ThreadShell
|
||||
session={session(chatId)}
|
||||
title={temporary ? "Temporary chat" : "Regular chat"}
|
||||
temporary={temporary}
|
||||
temporaryChatIds={temporaryChatIds}
|
||||
onToggleSidebar={() => {}}
|
||||
/>,
|
||||
);
|
||||
const retainedTemporaryChats = ["temporary-live"];
|
||||
const { rerender } = render(view("temporary-live", true, retainedTemporaryChats));
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||
target: { value: "keep this only in memory" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
await waitFor(() => expectSendMessageWithTurn(
|
||||
client,
|
||||
"temporary-live",
|
||||
"keep this only in memory",
|
||||
));
|
||||
|
||||
rerender(view("regular", false, retainedTemporaryChats));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("keep this only in memory")).not.toBeInTheDocument();
|
||||
});
|
||||
rerender(view("temporary-live", true, retainedTemporaryChats));
|
||||
expect(screen.getByText("keep this only in memory")).toBeInTheDocument();
|
||||
|
||||
rerender(view("temporary-cleared", true, ["temporary-cleared"]));
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText("keep this only in memory")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("highlights sent skill references without skill metadata", async () => {
|
||||
const client = makeClient();
|
||||
render(wrap(
|
||||
@@ -944,6 +990,7 @@ describe("ThreadShell", () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1));
|
||||
expect(onCreateChat).toHaveBeenCalledWith(null, "start for real");
|
||||
expect(onNewChat).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1224,7 +1271,7 @@ describe("ThreadShell", () => {
|
||||
|
||||
const greeting = screen.getByRole("heading", { level: 1, name: HERO_GREETING_PATTERN });
|
||||
expect(greeting).toHaveAttribute("data-testid", "hero-greeting");
|
||||
expect(greeting).toHaveClass("whitespace-nowrap");
|
||||
expect(greeting).toHaveClass("select-none", "whitespace-nowrap");
|
||||
expect(screen.getByPlaceholderText("Ask anything...")).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Write code" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Create a project plan" })).not.toBeInTheDocument();
|
||||
|
||||
@@ -76,6 +76,7 @@ function fakeClient() {
|
||||
return () => set!.delete(h);
|
||||
},
|
||||
sendMessage: vi.fn(),
|
||||
finishRunLocally: vi.fn(),
|
||||
newChat: vi.fn(),
|
||||
forkChat: vi.fn(),
|
||||
attach: vi.fn(),
|
||||
@@ -2247,6 +2248,7 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
|
||||
expect(fake.client.sendMessage).toHaveBeenLastCalledWith("chat-stop", "/stop");
|
||||
expect(fake.client.finishRunLocally).toHaveBeenCalledWith("chat-stop");
|
||||
expect(result.current.isStreaming).toBe(false);
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages[0].content).toBe("long task");
|
||||
|
||||
Reference in New Issue
Block a user