mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-08 21:38:40 +03:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15d7e7c822 | ||
|
|
db6c9effc3 | ||
|
|
0cb7dd5cc9 | ||
|
|
e1894d6f0b | ||
|
|
5eb818e800 | ||
|
|
4c387f6633 | ||
|
|
e152e7bc0b | ||
|
|
e26e09c205 | ||
|
|
f3bbb543d0 | ||
|
|
b1030ab131 | ||
|
|
39bb20c76b |
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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={}),
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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" && (
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,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 () => {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user