mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-05-20 00:22:31 +00:00
feat: polish trace delivery and slash menu UX
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
321c565ec4
commit
9d50f1b933
@ -238,6 +238,9 @@ nanobot channels login <channel_name> --force # re-authenticate
|
|||||||
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
|
| `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. |
|
||||||
| `is_running` | Returns `self._running`. |
|
| `is_running` | Returns `self._running`. |
|
||||||
| `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. |
|
| `login(force=False)` | Perform interactive login (e.g. QR code scan). Returns `True` if already authenticated or login succeeds. Override in subclasses that support interactive login. |
|
||||||
|
| `send_reasoning_delta(chat_id, delta, metadata?)` | Optional hook for streamed model reasoning/thinking content. Default is no-op. |
|
||||||
|
| `send_reasoning_end(chat_id, metadata?)` | Optional hook marking the end of a reasoning block. Default is no-op. |
|
||||||
|
| `send_reasoning(msg)` | Optional one-shot reasoning fallback. Default translates to `send_reasoning_delta()` + `send_reasoning_end()`. |
|
||||||
|
|
||||||
### Optional (streaming)
|
### Optional (streaming)
|
||||||
|
|
||||||
@ -350,6 +353,112 @@ When `streaming` is `false` (default) or omitted, only `send()` is called — no
|
|||||||
| `async send_delta(chat_id, delta, metadata?)` | Override to handle streaming chunks. No-op by default. |
|
| `async send_delta(chat_id, delta, metadata?)` | Override to handle streaming chunks. No-op by default. |
|
||||||
| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
|
| `supports_streaming` (property) | Returns `True` when config has `streaming: true` **and** subclass overrides `send_delta`. |
|
||||||
|
|
||||||
|
## Progress, Tool Hints, and Reasoning
|
||||||
|
|
||||||
|
Besides normal assistant text, nanobot can emit low-emphasis trace blocks. These are intended for UI affordances like status rows, collapsible "used tools" groups, or reasoning/thinking blocks. Platforms that do not have a good place for them can ignore them safely.
|
||||||
|
|
||||||
|
### Progress and Tool Hints
|
||||||
|
|
||||||
|
Progress and tool hints arrive through the normal `send(msg)` path. Check `msg.metadata` before rendering:
|
||||||
|
|
||||||
|
```python
|
||||||
|
async def send(self, msg: OutboundMessage) -> None:
|
||||||
|
meta = msg.metadata or {}
|
||||||
|
|
||||||
|
if meta.get("_tool_hint"):
|
||||||
|
# A short tool breadcrumb, e.g. read_file("config.json")
|
||||||
|
await self._send_trace(msg.chat_id, msg.content, kind="tool")
|
||||||
|
return
|
||||||
|
|
||||||
|
if meta.get("_progress"):
|
||||||
|
# Generic non-final status, e.g. "Thinking..." or "Running command..."
|
||||||
|
await self._send_trace(msg.chat_id, msg.content, kind="progress")
|
||||||
|
return
|
||||||
|
|
||||||
|
await self._send_message(msg.chat_id, msg.content, media=msg.media)
|
||||||
|
```
|
||||||
|
|
||||||
|
Tool hints are off by default for most channels. Users can enable them globally or per channel:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"sendToolHints": true,
|
||||||
|
"webhook": {
|
||||||
|
"enabled": true,
|
||||||
|
"sendToolHints": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reasoning Blocks
|
||||||
|
|
||||||
|
Reasoning is delivered through dedicated optional hooks, not `send()`. Override `send_reasoning_delta()` and `send_reasoning_end()` if your platform can show model reasoning as a subdued/collapsible block. The default implementation is a no-op, so unsupported channels simply drop reasoning content.
|
||||||
|
|
||||||
|
```python
|
||||||
|
class WebhookChannel(BaseChannel):
|
||||||
|
name = "webhook"
|
||||||
|
display_name = "Webhook"
|
||||||
|
|
||||||
|
def __init__(self, config: Any, bus: MessageBus):
|
||||||
|
if isinstance(config, dict):
|
||||||
|
config = WebhookConfig(**config)
|
||||||
|
super().__init__(config, bus)
|
||||||
|
self._reasoning_buffers: dict[str, str] = {}
|
||||||
|
|
||||||
|
async def send_reasoning_delta(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
delta: str,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
meta = metadata or {}
|
||||||
|
stream_id = str(meta.get("_stream_id") or chat_id)
|
||||||
|
self._reasoning_buffers[stream_id] = self._reasoning_buffers.get(stream_id, "") + delta
|
||||||
|
await self._update_reasoning_block(chat_id, self._reasoning_buffers[stream_id], final=False)
|
||||||
|
|
||||||
|
async def send_reasoning_end(
|
||||||
|
self,
|
||||||
|
chat_id: str,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
meta = metadata or {}
|
||||||
|
stream_id = str(meta.get("_stream_id") or chat_id)
|
||||||
|
text = self._reasoning_buffers.pop(stream_id, "")
|
||||||
|
if text:
|
||||||
|
await self._update_reasoning_block(chat_id, text, final=True)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reasoning metadata flags:**
|
||||||
|
|
||||||
|
| Flag | Meaning |
|
||||||
|
|------|---------|
|
||||||
|
| `_reasoning_delta: True` | A reasoning/thinking chunk; `delta` contains the new text. |
|
||||||
|
| `_reasoning_end: True` | The current reasoning block is complete; `delta` is empty. |
|
||||||
|
| `_reasoning: True` | Legacy one-shot reasoning. `BaseChannel.send_reasoning()` converts it to delta + end. |
|
||||||
|
| `_stream_id` | Stable id for this assistant turn/segment. Use it to key buffers instead of only `chat_id`. |
|
||||||
|
|
||||||
|
Reasoning visibility is controlled by `showReasoning` globally or per channel:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"channels": {
|
||||||
|
"showReasoning": true,
|
||||||
|
"webhook": {
|
||||||
|
"enabled": true,
|
||||||
|
"showReasoning": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Recommended rendering:
|
||||||
|
|
||||||
|
- Render tool hints and progress as trace/status UI, not as normal assistant replies.
|
||||||
|
- Render reasoning with lower visual emphasis and collapse it after completion when the platform supports that.
|
||||||
|
- Keep reasoning separate from final answer text. A final answer still arrives through `send()` or `send_delta()`.
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
### Why Pydantic model is required
|
### Why Pydantic model is required
|
||||||
|
|||||||
@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import dataclasses
|
import dataclasses
|
||||||
import json
|
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
from contextlib import AsyncExitStack, nullcontext, suppress
|
from contextlib import AsyncExitStack, nullcontext, suppress
|
||||||
@ -15,11 +14,12 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable
|
|||||||
|
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.agent import model_presets as preset_helpers
|
||||||
from nanobot.agent.autocompact import AutoCompact
|
from nanobot.agent.autocompact import AutoCompact
|
||||||
from nanobot.agent.context import ContextBuilder
|
from nanobot.agent.context import ContextBuilder
|
||||||
from nanobot.agent.hook import AgentHook, AgentHookContext, CompositeHook
|
from nanobot.agent.hook import AgentHook, CompositeHook
|
||||||
from nanobot.agent.memory import Consolidator, Dream
|
from nanobot.agent.memory import Consolidator, Dream
|
||||||
from nanobot.agent import model_presets as preset_helpers
|
from nanobot.agent.progress_hook import AgentProgressHook
|
||||||
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
|
from nanobot.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
|
||||||
from nanobot.agent.subagent import SubagentManager
|
from nanobot.agent.subagent import SubagentManager
|
||||||
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
|
from nanobot.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
|
||||||
@ -35,15 +35,9 @@ from nanobot.providers.factory import ProviderSnapshot
|
|||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.utils.artifacts import generated_image_paths_from_messages
|
from nanobot.utils.artifacts import generated_image_paths_from_messages
|
||||||
from nanobot.utils.document import extract_documents
|
from nanobot.utils.document import extract_documents
|
||||||
from nanobot.utils.helpers import IncrementalThinkExtractor, image_placeholder_text
|
from nanobot.utils.helpers import image_placeholder_text
|
||||||
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
from nanobot.utils.helpers import truncate_text as truncate_text_fn
|
||||||
from nanobot.utils.image_generation_intent import image_generation_prompt
|
from nanobot.utils.image_generation_intent import image_generation_prompt
|
||||||
from nanobot.utils.progress_events import (
|
|
||||||
build_tool_event_finish_payloads,
|
|
||||||
build_tool_event_start_payload,
|
|
||||||
invoke_on_progress,
|
|
||||||
on_progress_accepts_tool_events,
|
|
||||||
)
|
|
||||||
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
|
||||||
from nanobot.utils.webui_titles import mark_webui_session, maybe_generate_webui_title_after_turn
|
from nanobot.utils.webui_titles import mark_webui_session, maybe_generate_webui_title_after_turn
|
||||||
|
|
||||||
@ -59,148 +53,6 @@ if TYPE_CHECKING:
|
|||||||
UNIFIED_SESSION_KEY = "unified:default"
|
UNIFIED_SESSION_KEY = "unified:default"
|
||||||
|
|
||||||
|
|
||||||
class _LoopHook(AgentHook):
|
|
||||||
"""Core hook for the main loop."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
agent_loop: AgentLoop,
|
|
||||||
on_progress: Callable[..., Awaitable[None]] | None = None,
|
|
||||||
on_stream: Callable[[str], Awaitable[None]] | None = None,
|
|
||||||
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
|
||||||
*,
|
|
||||||
channel: str = "cli",
|
|
||||||
chat_id: str = "direct",
|
|
||||||
message_id: str | None = None,
|
|
||||||
metadata: dict[str, Any] | None = None,
|
|
||||||
session_key: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(reraise=True)
|
|
||||||
self._loop = agent_loop
|
|
||||||
self._on_progress = on_progress
|
|
||||||
self._on_stream = on_stream
|
|
||||||
self._on_stream_end = on_stream_end
|
|
||||||
self._channel = channel
|
|
||||||
self._chat_id = chat_id
|
|
||||||
self._message_id = message_id
|
|
||||||
self._metadata = metadata or {}
|
|
||||||
self._session_key = session_key
|
|
||||||
self._stream_buf = ""
|
|
||||||
self._think_extractor = IncrementalThinkExtractor()
|
|
||||||
self._reasoning_open = False
|
|
||||||
|
|
||||||
def wants_streaming(self) -> bool:
|
|
||||||
return self._on_stream is not None
|
|
||||||
|
|
||||||
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
|
|
||||||
from nanobot.utils.helpers import strip_think
|
|
||||||
|
|
||||||
prev_clean = strip_think(self._stream_buf)
|
|
||||||
self._stream_buf += delta
|
|
||||||
new_clean = strip_think(self._stream_buf)
|
|
||||||
incremental = new_clean[len(prev_clean) :]
|
|
||||||
|
|
||||||
if await self._think_extractor.feed(self._stream_buf, self.emit_reasoning):
|
|
||||||
context.streamed_reasoning = True
|
|
||||||
|
|
||||||
if incremental:
|
|
||||||
# Answer text has started — close any open reasoning segment so
|
|
||||||
# the UI can lock the bubble before the answer renders below it.
|
|
||||||
await self.emit_reasoning_end()
|
|
||||||
if self._on_stream:
|
|
||||||
await self._on_stream(incremental)
|
|
||||||
|
|
||||||
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
|
|
||||||
await self.emit_reasoning_end()
|
|
||||||
if self._on_stream_end:
|
|
||||||
await self._on_stream_end(resuming=resuming)
|
|
||||||
self._stream_buf = ""
|
|
||||||
self._think_extractor.reset()
|
|
||||||
|
|
||||||
async def before_iteration(self, context: AgentHookContext) -> None:
|
|
||||||
self._loop._current_iteration = context.iteration
|
|
||||||
logger.debug(
|
|
||||||
"Starting agent loop iteration {} for session {}",
|
|
||||||
context.iteration,
|
|
||||||
self._session_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
|
||||||
if self._on_progress:
|
|
||||||
if not self._on_stream and not context.streamed_content:
|
|
||||||
thought = self._loop._strip_think(
|
|
||||||
context.response.content if context.response else None
|
|
||||||
)
|
|
||||||
if thought:
|
|
||||||
await self._on_progress(thought)
|
|
||||||
tool_hint = self._loop._strip_think(self._loop._tool_hint(context.tool_calls))
|
|
||||||
tool_events = [build_tool_event_start_payload(tc) for tc in context.tool_calls]
|
|
||||||
await invoke_on_progress(
|
|
||||||
self._on_progress,
|
|
||||||
tool_hint,
|
|
||||||
tool_hint=True,
|
|
||||||
tool_events=tool_events,
|
|
||||||
)
|
|
||||||
for tc in context.tool_calls:
|
|
||||||
args_str = json.dumps(tc.arguments, ensure_ascii=False)
|
|
||||||
logger.info("Tool call: {}({})", tc.name, args_str[:200])
|
|
||||||
self._loop._set_tool_context(
|
|
||||||
self._channel,
|
|
||||||
self._chat_id,
|
|
||||||
self._message_id,
|
|
||||||
self._metadata,
|
|
||||||
session_key=self._session_key,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
|
||||||
"""Publish a reasoning chunk; channel plugins decide whether to render.
|
|
||||||
|
|
||||||
Each call is one delta in a streaming session. ``emit_reasoning_end``
|
|
||||||
closes the segment. The loop is intentionally not the gate:
|
|
||||||
``ChannelsConfig.show_reasoning`` is a default that ``ChannelManager``
|
|
||||||
and ``BaseChannel.send_reasoning_delta`` consult per channel — a
|
|
||||||
channel without a low-emphasis UI primitive keeps the base no-op
|
|
||||||
and the content drops at the dispatch boundary.
|
|
||||||
"""
|
|
||||||
if self._on_progress and reasoning_content:
|
|
||||||
self._reasoning_open = True
|
|
||||||
await self._on_progress(reasoning_content, reasoning=True)
|
|
||||||
|
|
||||||
async def emit_reasoning_end(self) -> None:
|
|
||||||
"""Close the current reasoning stream segment, if any was open."""
|
|
||||||
if self._reasoning_open and self._on_progress:
|
|
||||||
self._reasoning_open = False
|
|
||||||
await self._on_progress("", reasoning_end=True)
|
|
||||||
else:
|
|
||||||
self._reasoning_open = False
|
|
||||||
|
|
||||||
async def after_iteration(self, context: AgentHookContext) -> None:
|
|
||||||
if (
|
|
||||||
self._on_progress
|
|
||||||
and context.tool_calls
|
|
||||||
and context.tool_events
|
|
||||||
and on_progress_accepts_tool_events(self._on_progress)
|
|
||||||
):
|
|
||||||
tool_events = build_tool_event_finish_payloads(context)
|
|
||||||
if tool_events:
|
|
||||||
await invoke_on_progress(
|
|
||||||
self._on_progress,
|
|
||||||
"",
|
|
||||||
tool_hint=False,
|
|
||||||
tool_events=tool_events,
|
|
||||||
)
|
|
||||||
u = context.usage or {}
|
|
||||||
logger.debug(
|
|
||||||
"LLM usage: prompt={} completion={} cached={}",
|
|
||||||
u.get("prompt_tokens", 0),
|
|
||||||
u.get("completion_tokens", 0),
|
|
||||||
u.get("cached_tokens", 0),
|
|
||||||
)
|
|
||||||
|
|
||||||
def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None:
|
|
||||||
return self._loop._strip_think(content)
|
|
||||||
|
|
||||||
|
|
||||||
class TurnState(Enum):
|
class TurnState(Enum):
|
||||||
RESTORE = auto()
|
RESTORE = auto()
|
||||||
COMPACT = auto()
|
COMPACT = auto()
|
||||||
@ -651,26 +503,11 @@ class AgentLoop:
|
|||||||
if tool and isinstance(tool, ContextAware):
|
if tool and isinstance(tool, ContextAware):
|
||||||
tool.set_context(request_ctx)
|
tool.set_context(request_ctx)
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _strip_think(text: str | None) -> str | None:
|
|
||||||
"""Remove <think>…</think> blocks that some models embed in content."""
|
|
||||||
if not text:
|
|
||||||
return None
|
|
||||||
from nanobot.utils.helpers import strip_think
|
|
||||||
|
|
||||||
return strip_think(text) or None
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _runtime_chat_id(msg: InboundMessage) -> str:
|
def _runtime_chat_id(msg: InboundMessage) -> str:
|
||||||
"""Return the chat id shown in runtime metadata for the model."""
|
"""Return the chat id shown in runtime metadata for the model."""
|
||||||
return str(msg.metadata.get("context_chat_id") or msg.chat_id)
|
return str(msg.metadata.get("context_chat_id") or msg.chat_id)
|
||||||
|
|
||||||
def _tool_hint(self, tool_calls: list) -> str:
|
|
||||||
"""Format tool calls as concise hints with smart abbreviation."""
|
|
||||||
from nanobot.utils.tool_hints import format_tool_hints
|
|
||||||
|
|
||||||
return format_tool_hints(tool_calls, max_length=self.tool_hint_max_length)
|
|
||||||
|
|
||||||
async def _build_bus_progress_callback(
|
async def _build_bus_progress_callback(
|
||||||
self, msg: InboundMessage
|
self, msg: InboundMessage
|
||||||
) -> Callable[..., Awaitable[None]]:
|
) -> Callable[..., Awaitable[None]]:
|
||||||
@ -834,8 +671,7 @@ class AgentLoop:
|
|||||||
"""
|
"""
|
||||||
self._sync_subagent_runtime_limits()
|
self._sync_subagent_runtime_limits()
|
||||||
|
|
||||||
loop_hook = _LoopHook(
|
loop_hook = AgentProgressHook(
|
||||||
self,
|
|
||||||
on_progress=on_progress,
|
on_progress=on_progress,
|
||||||
on_stream=on_stream,
|
on_stream=on_stream,
|
||||||
on_stream_end=on_stream_end,
|
on_stream_end=on_stream_end,
|
||||||
@ -844,6 +680,9 @@ class AgentLoop:
|
|||||||
message_id=message_id,
|
message_id=message_id,
|
||||||
metadata=metadata,
|
metadata=metadata,
|
||||||
session_key=session_key,
|
session_key=session_key,
|
||||||
|
tool_hint_max_length=self.tool_hint_max_length,
|
||||||
|
set_tool_context=self._set_tool_context,
|
||||||
|
on_iteration=lambda iteration: setattr(self, "_current_iteration", iteration),
|
||||||
)
|
)
|
||||||
hook: AgentHook = (
|
hook: AgentHook = (
|
||||||
CompositeHook([loop_hook] + self._extra_hooks) if self._extra_hooks else loop_hook
|
CompositeHook([loop_hook] + self._extra_hooks) if self._extra_hooks else loop_hook
|
||||||
|
|||||||
178
nanobot/agent/progress_hook.py
Normal file
178
nanobot/agent/progress_hook.py
Normal file
@ -0,0 +1,178 @@
|
|||||||
|
"""Agent hook that adapts runner events into channel progress UI."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import inspect
|
||||||
|
import json
|
||||||
|
from typing import Any, Awaitable, Callable
|
||||||
|
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
from nanobot.agent.hook import AgentHook, AgentHookContext
|
||||||
|
from nanobot.utils.helpers import IncrementalThinkExtractor, strip_think
|
||||||
|
from nanobot.utils.progress_events import (
|
||||||
|
build_tool_event_finish_payloads,
|
||||||
|
build_tool_event_start_payload,
|
||||||
|
invoke_on_progress,
|
||||||
|
on_progress_accepts_tool_events,
|
||||||
|
)
|
||||||
|
from nanobot.utils.tool_hints import format_tool_hints
|
||||||
|
|
||||||
|
|
||||||
|
class AgentProgressHook(AgentHook):
|
||||||
|
"""Translate runner lifecycle events into user-visible progress signals."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
on_progress: Callable[..., Awaitable[None]] | None = None,
|
||||||
|
on_stream: Callable[[str], Awaitable[None]] | None = None,
|
||||||
|
on_stream_end: Callable[..., Awaitable[None]] | None = None,
|
||||||
|
*,
|
||||||
|
channel: str = "cli",
|
||||||
|
chat_id: str = "direct",
|
||||||
|
message_id: str | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
session_key: str | None = None,
|
||||||
|
tool_hint_max_length: int = 40,
|
||||||
|
set_tool_context: Callable[..., None] | None = None,
|
||||||
|
on_iteration: Callable[[int], None] | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(reraise=True)
|
||||||
|
self._on_progress = on_progress
|
||||||
|
self._on_stream = on_stream
|
||||||
|
self._on_stream_end = on_stream_end
|
||||||
|
self._channel = channel
|
||||||
|
self._chat_id = chat_id
|
||||||
|
self._message_id = message_id
|
||||||
|
self._metadata = metadata or {}
|
||||||
|
self._session_key = session_key
|
||||||
|
self._tool_hint_max_length = tool_hint_max_length
|
||||||
|
self._set_tool_context = set_tool_context
|
||||||
|
self._on_iteration = on_iteration
|
||||||
|
self._stream_buf = ""
|
||||||
|
self._think_extractor = IncrementalThinkExtractor()
|
||||||
|
self._reasoning_open = False
|
||||||
|
|
||||||
|
def wants_streaming(self) -> bool:
|
||||||
|
return self._on_stream is not None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _strip_think(text: str | None) -> str | None:
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
return strip_think(text) or None
|
||||||
|
|
||||||
|
def _tool_hint(self, tool_calls: list[Any]) -> str:
|
||||||
|
return format_tool_hints(tool_calls, max_length=self._tool_hint_max_length)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _on_progress_accepts(cb: Callable[..., Any], name: str) -> bool:
|
||||||
|
try:
|
||||||
|
sig = inspect.signature(cb)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
|
||||||
|
return True
|
||||||
|
return name in sig.parameters
|
||||||
|
|
||||||
|
async def on_stream(self, context: AgentHookContext, delta: str) -> None:
|
||||||
|
prev_clean = strip_think(self._stream_buf)
|
||||||
|
self._stream_buf += delta
|
||||||
|
new_clean = strip_think(self._stream_buf)
|
||||||
|
incremental = new_clean[len(prev_clean) :]
|
||||||
|
|
||||||
|
if await self._think_extractor.feed(self._stream_buf, self.emit_reasoning):
|
||||||
|
context.streamed_reasoning = True
|
||||||
|
|
||||||
|
if incremental:
|
||||||
|
# Answer text has started; close the reasoning segment so the UI can
|
||||||
|
# lock the bubble before the answer renders below it.
|
||||||
|
await self.emit_reasoning_end()
|
||||||
|
if self._on_stream:
|
||||||
|
await self._on_stream(incremental)
|
||||||
|
|
||||||
|
async def on_stream_end(self, context: AgentHookContext, *, resuming: bool) -> None:
|
||||||
|
await self.emit_reasoning_end()
|
||||||
|
if self._on_stream_end:
|
||||||
|
await self._on_stream_end(resuming=resuming)
|
||||||
|
self._stream_buf = ""
|
||||||
|
self._think_extractor.reset()
|
||||||
|
|
||||||
|
async def before_iteration(self, context: AgentHookContext) -> None:
|
||||||
|
if self._on_iteration:
|
||||||
|
self._on_iteration(context.iteration)
|
||||||
|
logger.debug(
|
||||||
|
"Starting agent loop iteration {} for session {}",
|
||||||
|
context.iteration,
|
||||||
|
self._session_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||||
|
if self._on_progress:
|
||||||
|
if not self._on_stream and not context.streamed_content:
|
||||||
|
thought = self._strip_think(context.response.content if context.response else None)
|
||||||
|
if thought:
|
||||||
|
await self._on_progress(thought)
|
||||||
|
tool_hint = self._strip_think(self._tool_hint(context.tool_calls))
|
||||||
|
tool_events = [build_tool_event_start_payload(tc) for tc in context.tool_calls]
|
||||||
|
await invoke_on_progress(
|
||||||
|
self._on_progress,
|
||||||
|
tool_hint,
|
||||||
|
tool_hint=True,
|
||||||
|
tool_events=tool_events,
|
||||||
|
)
|
||||||
|
for tc in context.tool_calls:
|
||||||
|
args_str = json.dumps(tc.arguments, ensure_ascii=False)
|
||||||
|
logger.info("Tool call: {}({})", tc.name, args_str[:200])
|
||||||
|
if self._set_tool_context:
|
||||||
|
self._set_tool_context(
|
||||||
|
self._channel,
|
||||||
|
self._chat_id,
|
||||||
|
self._message_id,
|
||||||
|
self._metadata,
|
||||||
|
session_key=self._session_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
||||||
|
"""Publish a reasoning chunk; channel plugins decide whether to render."""
|
||||||
|
if (
|
||||||
|
self._on_progress
|
||||||
|
and reasoning_content
|
||||||
|
and self._on_progress_accepts(self._on_progress, "reasoning")
|
||||||
|
):
|
||||||
|
self._reasoning_open = True
|
||||||
|
await self._on_progress(reasoning_content, reasoning=True)
|
||||||
|
|
||||||
|
async def emit_reasoning_end(self) -> None:
|
||||||
|
"""Close the current reasoning stream segment, if any was open."""
|
||||||
|
if self._reasoning_open and self._on_progress:
|
||||||
|
self._reasoning_open = False
|
||||||
|
await self._on_progress("", reasoning_end=True)
|
||||||
|
else:
|
||||||
|
self._reasoning_open = False
|
||||||
|
|
||||||
|
async def after_iteration(self, context: AgentHookContext) -> None:
|
||||||
|
if (
|
||||||
|
self._on_progress
|
||||||
|
and context.tool_calls
|
||||||
|
and context.tool_events
|
||||||
|
and on_progress_accepts_tool_events(self._on_progress)
|
||||||
|
):
|
||||||
|
tool_events = build_tool_event_finish_payloads(context)
|
||||||
|
if tool_events:
|
||||||
|
await invoke_on_progress(
|
||||||
|
self._on_progress,
|
||||||
|
"",
|
||||||
|
tool_hint=False,
|
||||||
|
tool_events=tool_events,
|
||||||
|
)
|
||||||
|
u = context.usage or {}
|
||||||
|
logger.debug(
|
||||||
|
"LLM usage: prompt={} completion={} cached={}",
|
||||||
|
u.get("prompt_tokens", 0),
|
||||||
|
u.get("completion_tokens", 0),
|
||||||
|
u.get("cached_tokens", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
def finalize_content(self, context: AgentHookContext, content: str | None) -> str | None:
|
||||||
|
return self._strip_think(content)
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
|
useLayoutEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
@ -77,6 +78,17 @@ const COMMAND_ICONS: Record<string, LucideIcon> = {
|
|||||||
type ImageAspectRatio = "auto" | "1:1" | "3:4" | "9:16" | "4:3" | "16:9";
|
type ImageAspectRatio = "auto" | "1:1" | "3:4" | "9:16" | "4:3" | "16:9";
|
||||||
|
|
||||||
const IMAGE_ASPECT_RATIOS: ImageAspectRatio[] = ["auto", "1:1", "3:4", "9:16", "4:3", "16:9"];
|
const IMAGE_ASPECT_RATIOS: ImageAspectRatio[] = ["auto", "1:1", "3:4", "9:16", "4:3", "16:9"];
|
||||||
|
const SLASH_PALETTE_GAP_PX = 8;
|
||||||
|
const SLASH_PALETTE_MAX_HEIGHT_PX = 288;
|
||||||
|
const SLASH_PALETTE_MIN_HEIGHT_PX = 144;
|
||||||
|
const SLASH_PALETTE_CHROME_PX = 64;
|
||||||
|
|
||||||
|
type SlashPalettePlacement = "above" | "below";
|
||||||
|
|
||||||
|
interface SlashPaletteLayout {
|
||||||
|
placement: SlashPalettePlacement;
|
||||||
|
maxHeight: number;
|
||||||
|
}
|
||||||
|
|
||||||
function slashCommandI18nKey(command: string): string {
|
function slashCommandI18nKey(command: string): string {
|
||||||
return command.replace(/^\//, "").replace(/-/g, "_");
|
return command.replace(/^\//, "").replace(/-/g, "_");
|
||||||
@ -96,6 +108,24 @@ function scrollNearestOverflowParent(target: EventTarget | null, deltaY: number)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getVisibleBounds(el: HTMLElement): { top: number; bottom: number } {
|
||||||
|
let top = 0;
|
||||||
|
let bottom = window.innerHeight;
|
||||||
|
let parent = el.parentElement;
|
||||||
|
|
||||||
|
while (parent) {
|
||||||
|
const style = window.getComputedStyle(parent);
|
||||||
|
if (/(auto|scroll|hidden|clip)/.test(style.overflowY)) {
|
||||||
|
const rect = parent.getBoundingClientRect();
|
||||||
|
top = Math.max(top, rect.top);
|
||||||
|
bottom = Math.min(bottom, rect.bottom);
|
||||||
|
}
|
||||||
|
parent = parent.parentElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { top, bottom };
|
||||||
|
}
|
||||||
|
|
||||||
export function ThreadComposer({
|
export function ThreadComposer({
|
||||||
onSend,
|
onSend,
|
||||||
disabled,
|
disabled,
|
||||||
@ -117,6 +147,7 @@ export function ThreadComposer({
|
|||||||
const [imageAspectRatio, setImageAspectRatio] = useState<ImageAspectRatio>("auto");
|
const [imageAspectRatio, setImageAspectRatio] = useState<ImageAspectRatio>("auto");
|
||||||
const [aspectMenuOpen, setAspectMenuOpen] = useState(false);
|
const [aspectMenuOpen, setAspectMenuOpen] = useState(false);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
const formRef = useRef<HTMLFormElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const aspectControlRef = useRef<HTMLDivElement>(null);
|
const aspectControlRef = useRef<HTMLDivElement>(null);
|
||||||
const chipRefs = useRef(new Map<string, HTMLButtonElement>());
|
const chipRefs = useRef(new Map<string, HTMLButtonElement>());
|
||||||
@ -221,6 +252,10 @@ export function ThreadComposer({
|
|||||||
}, [slashCommands, slashQuery, t]);
|
}, [slashCommands, slashQuery, t]);
|
||||||
|
|
||||||
const showSlashMenu = filteredSlashCommands.length > 0;
|
const showSlashMenu = filteredSlashCommands.length > 0;
|
||||||
|
const [slashPaletteLayout, setSlashPaletteLayout] = useState<SlashPaletteLayout>({
|
||||||
|
placement: "above",
|
||||||
|
maxHeight: SLASH_PALETTE_MAX_HEIGHT_PX,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setSelectedCommandIndex(0);
|
setSelectedCommandIndex(0);
|
||||||
@ -232,6 +267,56 @@ export function ThreadComposer({
|
|||||||
}
|
}
|
||||||
}, [filteredSlashCommands.length, selectedCommandIndex]);
|
}, [filteredSlashCommands.length, selectedCommandIndex]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showSlashMenu) return;
|
||||||
|
|
||||||
|
const dismissOnPointerDown = (event: PointerEvent) => {
|
||||||
|
const target = event.target;
|
||||||
|
if (target instanceof Node && formRef.current?.contains(target)) return;
|
||||||
|
setSlashMenuDismissed(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("pointerdown", dismissOnPointerDown, true);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("pointerdown", dismissOnPointerDown, true);
|
||||||
|
};
|
||||||
|
}, [showSlashMenu]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!showSlashMenu) return;
|
||||||
|
|
||||||
|
const updateLayout = () => {
|
||||||
|
const form = formRef.current;
|
||||||
|
if (!form) return;
|
||||||
|
const rect = form.getBoundingClientRect();
|
||||||
|
if (rect.width === 0 && rect.height === 0) return;
|
||||||
|
|
||||||
|
const bounds = getVisibleBounds(form);
|
||||||
|
const spaceAbove = Math.max(0, rect.top - bounds.top - SLASH_PALETTE_GAP_PX);
|
||||||
|
const spaceBelow = Math.max(0, bounds.bottom - rect.bottom - SLASH_PALETTE_GAP_PX);
|
||||||
|
const placement: SlashPalettePlacement =
|
||||||
|
spaceAbove >= SLASH_PALETTE_MIN_HEIGHT_PX || spaceAbove >= spaceBelow
|
||||||
|
? "above"
|
||||||
|
: "below";
|
||||||
|
const available = placement === "above" ? spaceAbove : spaceBelow;
|
||||||
|
const maxHeight = Math.min(SLASH_PALETTE_MAX_HEIGHT_PX, available);
|
||||||
|
|
||||||
|
setSlashPaletteLayout((current) =>
|
||||||
|
current.placement === placement && current.maxHeight === maxHeight
|
||||||
|
? current
|
||||||
|
: { placement, maxHeight },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
updateLayout();
|
||||||
|
window.addEventListener("resize", updateLayout);
|
||||||
|
document.addEventListener("scroll", updateLayout, true);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("resize", updateLayout);
|
||||||
|
document.removeEventListener("scroll", updateLayout, true);
|
||||||
|
};
|
||||||
|
}, [filteredSlashCommands.length, showSlashMenu]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!aspectMenuOpen) return;
|
if (!aspectMenuOpen) return;
|
||||||
|
|
||||||
@ -398,6 +483,7 @@ export function ThreadComposer({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
|
ref={formRef}
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
submit();
|
submit();
|
||||||
@ -412,6 +498,7 @@ export function ThreadComposer({
|
|||||||
<SlashCommandPalette
|
<SlashCommandPalette
|
||||||
commands={filteredSlashCommands}
|
commands={filteredSlashCommands}
|
||||||
selectedIndex={selectedCommandIndex}
|
selectedIndex={selectedCommandIndex}
|
||||||
|
layout={slashPaletteLayout}
|
||||||
isHero={isHero}
|
isHero={isHero}
|
||||||
onHover={setSelectedCommandIndex}
|
onHover={setSelectedCommandIndex}
|
||||||
onChoose={chooseSlashCommand}
|
onChoose={chooseSlashCommand}
|
||||||
@ -634,6 +721,7 @@ export function ThreadComposer({
|
|||||||
interface SlashCommandPaletteProps {
|
interface SlashCommandPaletteProps {
|
||||||
commands: SlashCommand[];
|
commands: SlashCommand[];
|
||||||
selectedIndex: number;
|
selectedIndex: number;
|
||||||
|
layout: SlashPaletteLayout;
|
||||||
isHero: boolean;
|
isHero: boolean;
|
||||||
onHover: (index: number) => void;
|
onHover: (index: number) => void;
|
||||||
onChoose: (command: SlashCommand) => void;
|
onChoose: (command: SlashCommand) => void;
|
||||||
@ -695,17 +783,24 @@ function ImageAspectMenu({
|
|||||||
function SlashCommandPalette({
|
function SlashCommandPalette({
|
||||||
commands,
|
commands,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
|
layout,
|
||||||
isHero,
|
isHero,
|
||||||
onHover,
|
onHover,
|
||||||
onChoose,
|
onChoose,
|
||||||
}: SlashCommandPaletteProps) {
|
}: SlashCommandPaletteProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const listMaxHeight = Math.max(
|
||||||
|
0,
|
||||||
|
layout.maxHeight - SLASH_PALETTE_CHROME_PX,
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
role="listbox"
|
role="listbox"
|
||||||
aria-label={t("thread.composer.slash.ariaLabel")}
|
aria-label={t("thread.composer.slash.ariaLabel")}
|
||||||
|
style={{ maxHeight: layout.maxHeight }}
|
||||||
className={cn(
|
className={cn(
|
||||||
"absolute bottom-full left-1/2 z-30 mb-2 max-h-[22rem] w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
|
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
|
||||||
|
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
|
||||||
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)]",
|
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)]",
|
||||||
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
|
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
|
||||||
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
|
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
|
||||||
@ -714,7 +809,7 @@ function SlashCommandPalette({
|
|||||||
<div className="px-2 pb-1 pt-1 text-[11px] font-medium tracking-[0.08em] text-muted-foreground/70">
|
<div className="px-2 pb-1 pt-1 text-[11px] font-medium tracking-[0.08em] text-muted-foreground/70">
|
||||||
{t("thread.composer.slash.label")}
|
{t("thread.composer.slash.label")}
|
||||||
</div>
|
</div>
|
||||||
<div className="max-h-[18rem] overflow-y-auto pr-0.5">
|
<div className="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
|
||||||
{commands.map((command, index) => {
|
{commands.map((command, index) => {
|
||||||
const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp;
|
const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp;
|
||||||
const selected = index === selectedIndex;
|
const selected = index === selectedIndex;
|
||||||
|
|||||||
@ -260,6 +260,7 @@ export function ThreadShell({
|
|||||||
}
|
}
|
||||||
modelLabel={toModelBadgeLabel(modelName)}
|
modelLabel={toModelBadgeLabel(modelName)}
|
||||||
variant="hero"
|
variant="hero"
|
||||||
|
slashCommands={slashCommands}
|
||||||
imageMode={heroImageMode}
|
imageMode={heroImageMode}
|
||||||
onImageModeChange={setHeroImageMode}
|
onImageModeChange={setHeroImageMode}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { fireEvent, render, screen } from "@testing-library/react";
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { 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";
|
||||||
import type { SlashCommand } from "@/lib/types";
|
import type { SlashCommand } from "@/lib/types";
|
||||||
@ -19,6 +19,33 @@ const COMMANDS: SlashCommand[] = [
|
|||||||
argHint: "[n]",
|
argHint: "[n]",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
const ORIGINAL_INNER_HEIGHT = window.innerHeight;
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
Object.defineProperty(window, "innerHeight", {
|
||||||
|
value: ORIGINAL_INNER_HEIGHT,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function rect(init: Partial<DOMRect>): DOMRect {
|
||||||
|
const top = init.top ?? 0;
|
||||||
|
const left = init.left ?? 0;
|
||||||
|
const width = init.width ?? 0;
|
||||||
|
const height = init.height ?? 0;
|
||||||
|
return {
|
||||||
|
x: init.x ?? left,
|
||||||
|
y: init.y ?? top,
|
||||||
|
top,
|
||||||
|
left,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
right: init.right ?? left + width,
|
||||||
|
bottom: init.bottom ?? top + height,
|
||||||
|
toJSON: () => ({}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
describe("ThreadComposer", () => {
|
describe("ThreadComposer", () => {
|
||||||
it("renders a readonly hero model composer when provided", () => {
|
it("renders a readonly hero model composer when provided", () => {
|
||||||
@ -74,7 +101,9 @@ describe("ThreadComposer", () => {
|
|||||||
const input = screen.getByLabelText("Message input");
|
const input = screen.getByLabelText("Message input");
|
||||||
fireEvent.change(input, { target: { value: "/" } });
|
fireEvent.change(input, { target: { value: "/" } });
|
||||||
|
|
||||||
expect(screen.getByRole("listbox", { name: "Slash commands" })).toBeInTheDocument();
|
const palette = screen.getByRole("listbox", { name: "Slash commands" });
|
||||||
|
expect(palette).toBeInTheDocument();
|
||||||
|
expect(palette).toHaveStyle({ maxHeight: "288px" });
|
||||||
expect(screen.getByRole("option", { name: /\/stop/i })).toHaveAttribute(
|
expect(screen.getByRole("option", { name: /\/stop/i })).toHaveAttribute(
|
||||||
"aria-selected",
|
"aria-selected",
|
||||||
"true",
|
"true",
|
||||||
@ -92,6 +121,55 @@ describe("ThreadComposer", () => {
|
|||||||
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("opens the slash command palette downward when there is more room below", async () => {
|
||||||
|
vi.spyOn(HTMLFormElement.prototype, "getBoundingClientRect").mockReturnValue(
|
||||||
|
rect({ top: 40, bottom: 160, width: 800, height: 120 }),
|
||||||
|
);
|
||||||
|
Object.defineProperty(window, "innerHeight", {
|
||||||
|
value: 330,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
render(
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={vi.fn()}
|
||||||
|
placeholder="Ask anything..."
|
||||||
|
slashCommands={COMMANDS}
|
||||||
|
variant="hero"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const input = screen.getByLabelText("Message input");
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: "/" } });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
const palette = screen.getByRole("listbox", { name: "Slash commands" });
|
||||||
|
expect(palette.className).toContain("top-full");
|
||||||
|
expect(palette).toHaveStyle({ maxHeight: "162px" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dismisses the slash command palette on outside click", () => {
|
||||||
|
render(
|
||||||
|
<div>
|
||||||
|
<button type="button">outside</button>
|
||||||
|
<ThreadComposer
|
||||||
|
onSend={vi.fn()}
|
||||||
|
placeholder="Type your message..."
|
||||||
|
slashCommands={COMMANDS}
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("Message input"), {
|
||||||
|
target: { value: "/" },
|
||||||
|
});
|
||||||
|
expect(screen.getByRole("listbox", { name: "Slash commands" })).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.pointerDown(screen.getByRole("button", { name: "outside" }));
|
||||||
|
|
||||||
|
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("sends image generation mode with automatic aspect ratio", () => {
|
it("sends image generation mode with automatic aspect ratio", () => {
|
||||||
const onSend = vi.fn();
|
const onSend = vi.fn();
|
||||||
render(
|
render(
|
||||||
|
|||||||
@ -573,7 +573,7 @@ describe("ThreadShell", () => {
|
|||||||
await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not open slash commands on the blank welcome page", async () => {
|
it("opens slash commands on the blank welcome page", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
@ -583,10 +583,11 @@ describe("ThreadShell", () => {
|
|||||||
return httpJson({
|
return httpJson({
|
||||||
commands: [
|
commands: [
|
||||||
{
|
{
|
||||||
command: "/stop",
|
command: "/history",
|
||||||
title: "Stop current task",
|
title: "Show conversation history",
|
||||||
description: "Cancel the active agent turn.",
|
description: "Print the last N persisted messages.",
|
||||||
icon: "square",
|
icon: "history",
|
||||||
|
arg_hint: "[n]",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@ -622,7 +623,8 @@ describe("ThreadShell", () => {
|
|||||||
target: { value: "/" },
|
target: { value: "/" },
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
|
expect(screen.getByRole("listbox", { name: "Slash commands" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("option", { name: /\/history/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("switches welcome quick actions when image mode is enabled", async () => {
|
it("switches welcome quick actions when image mode is enabled", async () => {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user