feat: polish trace delivery and slash menu UX

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xubin Ren 2026-05-13 08:47:34 +00:00
parent 321c565ec4
commit 9d50f1b933
7 changed files with 482 additions and 180 deletions

View File

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

View File

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

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

View File

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

View File

@ -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}
/> />

View File

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

View File

@ -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 () => {