diff --git a/docs/channel-plugin-guide.md b/docs/channel-plugin-guide.md index d37a92883..da668c9ee 100644 --- a/docs/channel-plugin-guide.md +++ b/docs/channel-plugin-guide.md @@ -238,6 +238,9 @@ nanobot channels login --force # re-authenticate | `supports_streaming` (property) | `True` when config has `"streaming": true` **and** subclass overrides `send_delta()`. | | `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. | +| `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) @@ -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. | | `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 ### Why Pydantic model is required diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py index 7897f89dd..9bfce39fb 100644 --- a/nanobot/agent/loop.py +++ b/nanobot/agent/loop.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio import dataclasses -import json import os import time from contextlib import AsyncExitStack, nullcontext, suppress @@ -15,11 +14,12 @@ from typing import TYPE_CHECKING, Any, Awaitable, Callable from loguru import logger +from nanobot.agent import model_presets as preset_helpers from nanobot.agent.autocompact import AutoCompact 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 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.subagent import SubagentManager 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.utils.artifacts import generated_image_paths_from_messages 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.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.webui_titles import mark_webui_session, maybe_generate_webui_title_after_turn @@ -59,148 +53,6 @@ if TYPE_CHECKING: 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): RESTORE = auto() COMPACT = auto() @@ -651,26 +503,11 @@ class AgentLoop: if tool and isinstance(tool, ContextAware): tool.set_context(request_ctx) - @staticmethod - def _strip_think(text: str | None) -> str | None: - """Remove 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 def _runtime_chat_id(msg: InboundMessage) -> str: """Return the chat id shown in runtime metadata for the model.""" 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( self, msg: InboundMessage ) -> Callable[..., Awaitable[None]]: @@ -834,8 +671,7 @@ class AgentLoop: """ self._sync_subagent_runtime_limits() - loop_hook = _LoopHook( - self, + loop_hook = AgentProgressHook( on_progress=on_progress, on_stream=on_stream, on_stream_end=on_stream_end, @@ -844,6 +680,9 @@ class AgentLoop: message_id=message_id, metadata=metadata, 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 = ( CompositeHook([loop_hook] + self._extra_hooks) if self._extra_hooks else loop_hook diff --git a/nanobot/agent/progress_hook.py b/nanobot/agent/progress_hook.py new file mode 100644 index 000000000..a9bf6a1e9 --- /dev/null +++ b/nanobot/agent/progress_hook.py @@ -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) diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 572ac3966..b95a7bbc4 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -77,6 +78,17 @@ const COMMAND_ICONS: Record = { 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 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 { 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({ onSend, disabled, @@ -117,6 +147,7 @@ export function ThreadComposer({ const [imageAspectRatio, setImageAspectRatio] = useState("auto"); const [aspectMenuOpen, setAspectMenuOpen] = useState(false); const textareaRef = useRef(null); + const formRef = useRef(null); const fileInputRef = useRef(null); const aspectControlRef = useRef(null); const chipRefs = useRef(new Map()); @@ -221,6 +252,10 @@ export function ThreadComposer({ }, [slashCommands, slashQuery, t]); const showSlashMenu = filteredSlashCommands.length > 0; + const [slashPaletteLayout, setSlashPaletteLayout] = useState({ + placement: "above", + maxHeight: SLASH_PALETTE_MAX_HEIGHT_PX, + }); useEffect(() => { setSelectedCommandIndex(0); @@ -232,6 +267,56 @@ export function ThreadComposer({ } }, [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(() => { if (!aspectMenuOpen) return; @@ -398,6 +483,7 @@ export function ThreadComposer({ return (
{ e.preventDefault(); submit(); @@ -412,6 +498,7 @@ export function ThreadComposer({ void; onChoose: (command: SlashCommand) => void; @@ -695,17 +783,24 @@ function ImageAspectMenu({ function SlashCommandPalette({ commands, selectedIndex, + layout, isHero, onHover, onChoose, }: SlashCommandPaletteProps) { const { t } = useTranslation(); + const listMaxHeight = Math.max( + 0, + layout.maxHeight - SLASH_PALETTE_CHROME_PX, + ); return (
{t("thread.composer.slash.label")}
-
+
{commands.map((command, index) => { const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp; const selected = index === selectedIndex; diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index c5c488de0..0d330c2a9 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -260,6 +260,7 @@ export function ThreadShell({ } modelLabel={toModelBadgeLabel(modelName)} variant="hero" + slashCommands={slashCommands} imageMode={heroImageMode} onImageModeChange={setHeroImageMode} /> diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index 7b147602a..015ff50ad 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -1,5 +1,5 @@ -import { fireEvent, render, screen } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { ThreadComposer } from "@/components/thread/ThreadComposer"; import type { SlashCommand } from "@/lib/types"; @@ -19,6 +19,33 @@ const COMMANDS: SlashCommand[] = [ 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 { + 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", () => { it("renders a readonly hero model composer when provided", () => { @@ -74,7 +101,9 @@ describe("ThreadComposer", () => { const input = screen.getByLabelText("Message input"); 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( "aria-selected", "true", @@ -92,6 +121,55 @@ describe("ThreadComposer", () => { 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( + , + ); + 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( +
+ + +
, + ); + + 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", () => { const onSend = vi.fn(); render( diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index 8dd999d6b..f9bf7db0c 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -573,7 +573,7 @@ describe("ThreadShell", () => { 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(); vi.stubGlobal( "fetch", @@ -583,10 +583,11 @@ describe("ThreadShell", () => { return httpJson({ commands: [ { - command: "/stop", - title: "Stop current task", - description: "Cancel the active agent turn.", - icon: "square", + command: "/history", + title: "Show conversation history", + description: "Print the last N persisted messages.", + icon: "history", + arg_hint: "[n]", }, ], }); @@ -622,7 +623,8 @@ describe("ThreadShell", () => { 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 () => {