Compare commits

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

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

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

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

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