diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py index 4838fcece..7d4d20625 100644 --- a/nanobot/channels/websocket.py +++ b/nanobot/channels/websocket.py @@ -32,6 +32,7 @@ from websockets.http11 import Response from nanobot.bus.events import OutboundMessage from nanobot.bus.queue import MessageBus from nanobot.channels.base import BaseChannel +from nanobot.command.builtin import builtin_command_palette from nanobot.config.paths import get_media_dir from nanobot.config.schema import Base from nanobot.utils.helpers import safe_filename @@ -553,6 +554,9 @@ class WebSocketChannel(BaseChannel): if got == "/api/settings": return self._handle_settings(request) + if got == "/api/commands": + return self._handle_commands(request) + if got == "/api/settings/update": return self._handle_settings_update(request) @@ -708,6 +712,11 @@ class WebSocketChannel(BaseChannel): return _http_error(401, "Unauthorized") return _http_json_response(self._settings_payload()) + def _handle_commands(self, request: WsRequest) -> Response: + if not self._check_api_token(request): + return _http_error(401, "Unauthorized") + return _http_json_response({"commands": builtin_command_palette()}) + def _handle_settings_update(self, request: WsRequest) -> Response: if not self._check_api_token(request): return _http_error(401, "Unauthorized") diff --git a/nanobot/command/builtin.py b/nanobot/command/builtin.py index 32444a4ba..b71a77f91 100644 --- a/nanobot/command/builtin.py +++ b/nanobot/command/builtin.py @@ -6,6 +6,7 @@ import asyncio import os import sys from contextlib import suppress +from dataclasses import dataclass from nanobot import __version__ from nanobot.bus.events import OutboundMessage @@ -14,6 +15,88 @@ from nanobot.utils.helpers import build_status_content from nanobot.utils.restart import set_restart_notice_to_env +@dataclass(frozen=True) +class BuiltinCommandSpec: + command: str + title: str + description: str + icon: str + arg_hint: str = "" + + def as_dict(self) -> dict[str, str]: + return { + "command": self.command, + "title": self.title, + "description": self.description, + "icon": self.icon, + "arg_hint": self.arg_hint, + } + + +BUILTIN_COMMAND_SPECS: tuple[BuiltinCommandSpec, ...] = ( + BuiltinCommandSpec( + "/new", + "New chat", + "Stop the current task and start a fresh conversation.", + "square-pen", + ), + BuiltinCommandSpec( + "/stop", + "Stop current task", + "Cancel the active agent turn for this chat.", + "square", + ), + BuiltinCommandSpec( + "/restart", + "Restart nanobot", + "Restart the bot process in place.", + "rotate-cw", + ), + BuiltinCommandSpec( + "/status", + "Show status", + "Display runtime, provider, and channel status.", + "activity", + ), + BuiltinCommandSpec( + "/history", + "Show conversation history", + "Print the last N persisted conversation messages.", + "history", + "[n]", + ), + BuiltinCommandSpec( + "/dream", + "Run Dream", + "Manually trigger memory consolidation.", + "sparkles", + ), + BuiltinCommandSpec( + "/dream-log", + "Show Dream log", + "Show what the last Dream consolidation changed.", + "book-open", + ), + BuiltinCommandSpec( + "/dream-restore", + "Restore memory", + "Revert memory to a previous Dream snapshot.", + "undo-2", + ), + BuiltinCommandSpec( + "/help", + "Show help", + "List available slash commands.", + "circle-help", + ), +) + + +def builtin_command_palette() -> list[dict[str, str]]: + """Return structured command metadata for UI command palettes.""" + return [spec.as_dict() for spec in BUILTIN_COMMAND_SPECS] + + async def cmd_stop(ctx: CommandContext) -> OutboundMessage: """Cancel all active tasks and subagents for the session.""" loop = ctx.loop @@ -378,18 +461,12 @@ async def cmd_help(ctx: CommandContext) -> OutboundMessage: def build_help_text() -> str: """Build canonical help text shared across channels.""" - lines = [ - "🐈 nanobot commands:", - "/new — Stop current task and start a new conversation", - "/stop — Stop the current task", - "/restart — Restart the bot", - "/status — Show bot status", - "/history [n] — Show the last N conversation messages (default 10)", - "/dream — Manually trigger Dream consolidation", - "/dream-log — Show what the last Dream changed", - "/dream-restore — Revert memory to a previous state", - "/help — Show available commands", - ] + lines = ["🐈 nanobot commands:"] + for spec in BUILTIN_COMMAND_SPECS: + command = spec.command + if spec.arg_hint: + command = f"{command} {spec.arg_hint}" + lines.append(f"{command} — {spec.description}") return "\n".join(lines) diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py index f20095388..e757551f2 100644 --- a/tests/channels/test_websocket_channel.py +++ b/tests/channels/test_websocket_channel.py @@ -563,6 +563,34 @@ async def test_settings_api_returns_safe_subset_and_updates_whitelist( await server_task +@pytest.mark.asyncio +async def test_commands_api_returns_slash_command_metadata(bus: MagicMock) -> None: + port = 29892 + channel = _ch(bus, port=port) + channel._api_tokens["tok"] = time.monotonic() + 300 + + server_task = asyncio.create_task(channel.start()) + await asyncio.sleep(0.3) + + try: + denied = await _http_get(f"http://127.0.0.1:{port}/api/commands") + assert denied.status_code == 401 + + response = await _http_get( + f"http://127.0.0.1:{port}/api/commands", + headers={"Authorization": "Bearer tok"}, + ) + assert response.status_code == 200 + body = response.json() + commands = {row["command"]: row for row in body["commands"]} + assert commands["/stop"]["title"] == "Stop current task" + assert commands["/history"]["arg_hint"] == "[n]" + assert all("description" in row for row in body["commands"]) + finally: + await channel.stop() + await server_task + + def test_settings_payload_normalizes_camel_case_provider( bus: MagicMock, monkeypatch, diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 5f86190b1..ac994f89e 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -7,11 +7,21 @@ import { type KeyboardEvent as ReactKeyboardEvent, } from "react"; import { + Activity, ArrowUp, + BookOpen, + CircleHelp, + History, ImageIcon, Loader2, Plus, + RotateCw, + Sparkles, + Square, + SquarePen, + Undo2, X, + type LucideIcon, } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -24,6 +34,7 @@ import { } from "@/hooks/useAttachedImages"; import { useClipboardAndDrop } from "@/hooks/useClipboardAndDrop"; import type { SendImage } from "@/hooks/useNanobotStream"; +import type { SlashCommand } from "@/lib/types"; import { cn } from "@/lib/utils"; /** ````: aligned with the server's MIME whitelist. SVG is @@ -43,6 +54,23 @@ interface ThreadComposerProps { isStreaming?: boolean; modelLabel?: string | null; variant?: "thread" | "hero"; + slashCommands?: SlashCommand[]; +} + +const COMMAND_ICONS: Record = { + activity: Activity, + "book-open": BookOpen, + "circle-help": CircleHelp, + history: History, + "rotate-cw": RotateCw, + sparkles: Sparkles, + square: Square, + "square-pen": SquarePen, + "undo-2": Undo2, +}; + +function slashCommandI18nKey(command: string): string { + return command.replace(/^\//, "").replace(/-/g, "_"); } export function ThreadComposer({ @@ -52,10 +80,13 @@ export function ThreadComposer({ isStreaming = false, modelLabel = null, variant = "thread", + slashCommands = [], }: ThreadComposerProps) { const { t } = useTranslation(); const [value, setValue] = useState(""); const [inlineError, setInlineError] = useState(null); + const [slashMenuDismissed, setSlashMenuDismissed] = useState(false); + const [selectedCommandIndex, setSelectedCommandIndex] = useState(0); const textareaRef = useRef(null); const fileInputRef = useRef(null); const chipRefs = useRef(new Map()); @@ -119,6 +150,66 @@ export function ThreadComposer({ && !hasErrors && (value.trim().length > 0 || readyImages.length > 0); + const slashQuery = useMemo(() => { + if (disabled || slashMenuDismissed || !value.startsWith("/")) return null; + const commandToken = value.slice(1); + if (/\s/.test(commandToken)) return null; + return commandToken.toLowerCase(); + }, [disabled, slashMenuDismissed, value]); + + const filteredSlashCommands = useMemo(() => { + if (slashQuery === null) return []; + return slashCommands + .filter((command) => { + const haystack = [ + command.command, + command.title, + command.description, + command.argHint ?? "", + t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.title`, { + defaultValue: "", + }), + t(`thread.composer.slash.commands.${slashCommandI18nKey(command.command)}.description`, { + defaultValue: "", + }), + ].join(" ").toLowerCase(); + return haystack.includes(slashQuery); + }) + .slice(0, 8); + }, [slashCommands, slashQuery, t]); + + const showSlashMenu = filteredSlashCommands.length > 0; + + useEffect(() => { + setSelectedCommandIndex(0); + }, [slashQuery]); + + useEffect(() => { + if (selectedCommandIndex >= filteredSlashCommands.length) { + setSelectedCommandIndex(0); + } + }, [filteredSlashCommands.length, selectedCommandIndex]); + + const resizeTextarea = useCallback(() => { + requestAnimationFrame(() => { + const el = textareaRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = `${Math.min(el.scrollHeight, 260)}px`; + el.focus(); + }); + }, []); + + const chooseSlashCommand = useCallback( + (command: SlashCommand) => { + setValue(command.argHint ? `${command.command} ` : command.command); + setSlashMenuDismissed(true); + setInlineError(null); + resizeTextarea(); + }, + [resizeTextarea], + ); + const submit = useCallback(() => { if (!canSend) return; const trimmed = value.trim(); @@ -142,16 +233,35 @@ export function ThreadComposer({ // Bubble owns the data URL copy; safe to revoke every staged blob // preview here without affecting the rendered message. clear(); - requestAnimationFrame(() => { - const el = textareaRef.current; - if (el) { - el.style.height = "auto"; - el.focus(); - } - }); - }, [canSend, clear, onSend, readyImages, value]); + setSlashMenuDismissed(false); + resizeTextarea(); + }, [canSend, clear, onSend, readyImages, resizeTextarea, value]); const onKeyDown = (e: ReactKeyboardEvent) => { + if (showSlashMenu) { + if (e.key === "ArrowDown") { + e.preventDefault(); + setSelectedCommandIndex((idx) => (idx + 1) % filteredSlashCommands.length); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + setSelectedCommandIndex( + (idx) => (idx - 1 + filteredSlashCommands.length) % filteredSlashCommands.length, + ); + return; + } + if (e.key === "Tab" || (e.key === "Enter" && !e.shiftKey)) { + e.preventDefault(); + chooseSlashCommand(filteredSlashCommands[selectedCommandIndex]); + return; + } + if (e.key === "Escape") { + e.preventDefault(); + setSlashMenuDismissed(true); + return; + } + } if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); submit(); @@ -213,8 +323,17 @@ export function ThreadComposer({ onDragOver={onDragOver} onDragLeave={onDragLeave} onDrop={onDrop} - className={cn("w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")} + className={cn("relative w-full", isHero ? "px-0" : "px-1 pb-1.5 pt-1 sm:px-0")} > + {showSlashMenu ? ( + + ) : null}
setValue(e.target.value)} + onChange={(e) => { + setValue(e.target.value); + setSlashMenuDismissed(false); + }} onInput={onInput} onKeyDown={onKeyDown} onPaste={onPaste} @@ -367,6 +489,106 @@ export function ThreadComposer({ ); } +interface SlashCommandPaletteProps { + commands: SlashCommand[]; + selectedIndex: number; + isHero: boolean; + onHover: (index: number) => void; + onChoose: (command: SlashCommand) => void; +} + +function SlashCommandPalette({ + commands, + selectedIndex, + isHero, + onHover, + onChoose, +}: SlashCommandPaletteProps) { + const { t } = useTranslation(); + return ( +
+
+ {t("thread.composer.slash.label")} +
+
+ {commands.map((command, index) => { + const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp; + const selected = index === selectedIndex; + const commandKey = slashCommandI18nKey(command.command); + const title = t(`thread.composer.slash.commands.${commandKey}.title`, { + defaultValue: command.title, + }); + const description = t(`thread.composer.slash.commands.${commandKey}.description`, { + defaultValue: command.description, + }); + return ( + + ); + })} +
+
+ {t("thread.composer.slash.navigateHint")} + {t("thread.composer.slash.selectHint")} + {t("thread.composer.slash.closeHint")} +
+
+ ); +} + interface AttachmentChipProps { image: AttachedImage; labelRemove: string; diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index 45b164b44..f15551ce5 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { BarChart3, BookOpen, @@ -17,7 +17,8 @@ import { StreamErrorNotice } from "@/components/thread/StreamErrorNotice"; import { ThreadViewport } from "@/components/thread/ThreadViewport"; import { useNanobotStream } from "@/hooks/useNanobotStream"; import { useSessionHistory } from "@/hooks/useSessions"; -import type { ChatSummary, UIMessage } from "@/lib/types"; +import { listSlashCommands } from "@/lib/api"; +import type { ChatSummary, SlashCommand, UIMessage } from "@/lib/types"; import { useClient } from "@/providers/ClientProvider"; interface ThreadShellProps { @@ -66,8 +67,9 @@ export function ThreadShell({ const chatId = session?.chatId ?? null; const historyKey = session?.key ?? null; const { messages: historical, loading, hasPendingToolCalls } = useSessionHistory(historyKey); - const { client, modelName } = useClient(); + const { client, modelName, token } = useClient(); const [booting, setBooting] = useState(false); + const [slashCommands, setSlashCommands] = useState([]); const pendingFirstRef = useRef(null); const messageCacheRef = useRef>(new Map()); const lastCachedChatIdRef = useRef(null); @@ -116,17 +118,24 @@ export function ThreadShell({ setMessages(historical); }, [chatId, historical, setMessages]); - useEffect(() => { - if (!chatId) return; + useLayoutEffect(() => { + if (!chatId) { + lastCachedChatIdRef.current = null; + return; + } + if (loading) return; // Skip the first cache write after a chat switch. During that render, // `messages` can still belong to the previous chat until the stream hook // resets its local state for the new session. if (lastCachedChatIdRef.current !== chatId) { lastCachedChatIdRef.current = chatId; + if (messages.length > 0) { + messageCacheRef.current.set(chatId, messages); + } return; } messageCacheRef.current.set(chatId, messages); - }, [chatId, messages]); + }, [chatId, loading, messages]); useEffect(() => { if (!chatId) return; @@ -146,6 +155,21 @@ export function ThreadShell({ setBooting(false); }, [chatId, client, setMessages]); + useEffect(() => { + let cancelled = false; + (async () => { + try { + const commands = await listSlashCommands(token); + if (!cancelled) setSlashCommands(commands); + } catch { + if (!cancelled) setSlashCommands([]); + } + })(); + return () => { + cancelled = true; + }; + }, [token]); + const handleWelcomeSend = useCallback( async (content: string) => { if (booting) return; @@ -222,6 +246,7 @@ export function ThreadShell({ } modelLabel={toModelBadgeLabel(modelName)} variant={showHeroComposer ? "hero" : "thread"} + slashCommands={slashCommands} /> ) : ( (`${base}/api/settings`, token); } +export async function listSlashCommands( + token: string, + base: string = "", +): Promise { + type Row = { + command: string; + title: string; + description: string; + icon: string; + arg_hint?: string; + }; + const body = await request<{ commands: Row[] }>(`${base}/api/commands`, token); + return body.commands.map((command) => ({ + command: command.command, + title: command.title, + description: command.description, + icon: command.icon, + argHint: command.arg_hint ?? "", + })); +} + export async function updateSettings( token: string, update: SettingsUpdate, diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index c2428115d..cc5e7ae29 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -89,6 +89,14 @@ export interface SettingsUpdate { provider?: string; } +export interface SlashCommand { + command: string; + title: string; + description: string; + icon: string; + argHint?: string; +} + export type ConnectionStatus = | "idle" | "connecting" diff --git a/webui/src/tests/api.test.ts b/webui/src/tests/api.test.ts index dc387d241..aa44651f5 100644 --- a/webui/src/tests/api.test.ts +++ b/webui/src/tests/api.test.ts @@ -1,6 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { deleteSession, fetchSessionMessages, listSessions, updateSettings } from "@/lib/api"; +import { + deleteSession, + fetchSessionMessages, + listSessions, + listSlashCommands, + updateSettings, +} from "@/lib/api"; describe("webui API helpers", () => { beforeEach(() => { @@ -72,4 +78,37 @@ describe("webui API helpers", () => { }, ]); }); + + it("maps slash command metadata from the commands endpoint", async () => { + vi.mocked(fetch).mockResolvedValueOnce({ + ok: true, + json: async () => ({ + commands: [ + { + command: "/history", + title: "Show conversation history", + description: "Print the last N messages.", + icon: "history", + arg_hint: "[n]", + }, + ], + }), + } as Response); + + await expect(listSlashCommands("tok")).resolves.toEqual([ + { + command: "/history", + title: "Show conversation history", + description: "Print the last N messages.", + icon: "history", + argHint: "[n]", + }, + ]); + expect(fetch).toHaveBeenCalledWith( + "/api/commands", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + ); + }); }); diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx index 66b029577..fb4496f71 100644 --- a/webui/src/tests/i18n.test.tsx +++ b/webui/src/tests/i18n.test.tsx @@ -4,6 +4,9 @@ import { describe, expect, it, vi } from "vitest"; import { LanguageSwitcher } from "@/components/LanguageSwitcher"; import { ThreadComposer } from "@/components/thread/ThreadComposer"; +import { resources } from "@/i18n"; + +const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"]; describe("webui i18n", () => { it("switches UI copy and document locale through the language switcher", async () => { @@ -41,4 +44,16 @@ describe("webui i18n", () => { expect(screen.getByLabelText("メッセージ入力欄")).toBeInTheDocument(); }); + + it("keeps welcome quick actions localized for every registered locale", () => { + for (const resource of Object.values(resources)) { + const empty = resource.common.thread.empty; + expect(empty.greeting).toBeTruthy(); + for (const key of QUICK_ACTION_KEYS) { + const action = empty.quickActions[key as keyof typeof empty.quickActions]; + expect(action.title).toBeTruthy(); + expect(action.prompt).toBeTruthy(); + } + } + }); }); diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index 3d5c14e75..9e776291a 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -1,7 +1,24 @@ -import { render, screen } from "@testing-library/react"; +import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import { ThreadComposer } from "@/components/thread/ThreadComposer"; +import type { SlashCommand } from "@/lib/types"; + +const COMMANDS: SlashCommand[] = [ + { + 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", + argHint: "[n]", + }, +]; describe("ThreadComposer", () => { it("renders a readonly hero model composer when provided", () => { @@ -43,4 +60,35 @@ describe("ThreadComposer", () => { expect(screen.getByRole("button", { name: "Attach image" }).className).toContain("bg-card"); expect(screen.getByRole("button", { name: "Send message" }).className).toContain("bg-foreground"); }); + + it("opens a slash command palette and inserts the selected command", () => { + const onSend = vi.fn(); + render( + , + ); + + const input = screen.getByLabelText("Message input"); + fireEvent.change(input, { target: { value: "/" } }); + + expect(screen.getByRole("listbox", { name: "Slash commands" })).toBeInTheDocument(); + expect(screen.getByRole("option", { name: /\/stop/i })).toHaveAttribute( + "aria-selected", + "true", + ); + + fireEvent.keyDown(input, { key: "ArrowDown" }); + expect(screen.getByRole("option", { name: /\/history/i })).toHaveAttribute( + "aria-selected", + "true", + ); + fireEvent.keyDown(input, { key: "Enter" }); + + expect(input).toHaveValue("/history "); + expect(onSend).not.toHaveBeenCalled(); + expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument(); + }); }); diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index 68a81d1e1..3dd47f6b8 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -434,6 +434,138 @@ describe("ThreadShell", () => { }); }); + it("keeps live assistant replies after visiting the blank new-chat page", async () => { + const client = makeClient(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("websocket%3Achat-a/messages")) { + return httpJson({ + key: "websocket:chat-a", + created_at: null, + updated_at: null, + // Simulate a stale history response that has not persisted the + // just-received assistant reply yet. + messages: [{ role: "user", content: "hello" }], + }); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + const { rerender } = render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + + await waitFor(() => expect(screen.getByText("hello")).toBeInTheDocument()); + await act(async () => { + client._emitChat("chat-a", { + event: "message", + chat_id: "chat-a", + text: "live assistant reply", + }); + }); + expect(screen.getByText("live assistant reply")).toBeInTheDocument(); + + await act(async () => { + rerender( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + }); + + expect(screen.queryByText("live assistant reply")).not.toBeInTheDocument(); + expect(screen.getByText("What can I do for you?")).toBeInTheDocument(); + + await act(async () => { + rerender( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + }); + + await waitFor(() => expect(screen.getByText("live assistant reply")).toBeInTheDocument()); + }); + + it("does not open slash commands on the blank welcome page", async () => { + const client = makeClient(); + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.endsWith("/api/commands")) { + return httpJson({ + commands: [ + { + command: "/stop", + title: "Stop current task", + description: "Cancel the active agent turn.", + icon: "square", + }, + ], + }); + } + return { + ok: false, + status: 404, + json: async () => ({}), + }; + }), + ); + + render( + wrap( + client, + {}} + onNewChat={() => {}} + />, + ), + ); + + await waitFor(() => expect(fetch).toHaveBeenCalledWith( + "/api/commands", + expect.objectContaining({ + headers: { Authorization: "Bearer tok" }, + }), + )); + + fireEvent.change(screen.getByLabelText("Message input"), { + target: { value: "/" }, + }); + + expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument(); + }); + it("surfaces a dismissible banner when the stream reports message_too_big", async () => { const client = makeClient(); const onNewChat = vi.fn().mockResolvedValue("chat-a"); @@ -454,6 +586,7 @@ describe("ThreadShell", () => { // No banner yet: only appears once the client emits a matching error. expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + await act(async () => {}); await act(async () => { client._emitError({ kind: "message_too_big" }); }); @@ -485,6 +618,7 @@ describe("ThreadShell", () => { ), ); + await act(async () => {}); await act(async () => { client._emitError({ kind: "message_too_big" }); });