feat(webui): add localized slash commands

Add a session-scoped slash command palette sourced from backend command metadata, and keep welcome-page quick actions localized across all WebUI languages.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Xubin Ren 2026-05-06 15:54:15 +00:00 committed by Xubin Ren
parent 49c07aa45a
commit ac18a8baad
20 changed files with 1258 additions and 38 deletions

View File

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

View File

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

View File

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

View File

@ -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";
/** ``<input accept>``: 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<string, LucideIcon> = {
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<string | null>(null);
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
const [selectedCommandIndex, setSelectedCommandIndex] = useState(0);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const chipRefs = useRef(new Map<string, HTMLButtonElement>());
@ -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<HTMLTextAreaElement>) => {
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 ? (
<SlashCommandPalette
commands={filteredSlashCommands}
selectedIndex={selectedCommandIndex}
isHero={isHero}
onHover={setSelectedCommandIndex}
onChoose={chooseSlashCommand}
/>
) : null}
<div
className={cn(
"relative mx-auto flex w-full flex-col overflow-hidden transition-all duration-200",
@ -257,7 +376,10 @@ export function ThreadComposer({
<textarea
ref={textareaRef}
value={value}
onChange={(e) => 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 (
<div
role="listbox"
aria-label={t("thread.composer.slash.ariaLabel")}
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",
"border-border/65 bg-popover/98 p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)] backdrop-blur",
"dark:border-white/10 dark:shadow-[0_22px_55px_rgba(0,0,0,0.45)]",
isHero ? "max-w-[58rem]" : "max-w-[49.5rem]",
)}
>
<div className="px-2 pb-1 pt-1 text-[11px] font-medium tracking-[0.08em] text-muted-foreground/70">
{t("thread.composer.slash.label")}
</div>
<div className="max-h-[18rem] overflow-y-auto pr-0.5">
{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 (
<button
key={command.command}
type="button"
role="option"
aria-selected={selected}
onMouseEnter={() => onHover(index)}
onMouseDown={(e) => {
e.preventDefault();
onChoose(command);
}}
className={cn(
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
selected
? "bg-primary/10 text-foreground"
: "text-foreground/86 hover:bg-accent/55",
)}
>
<span
className={cn(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-[10px] border",
selected
? "border-primary/25 bg-primary/12 text-primary"
: "border-border/65 bg-muted/45 text-muted-foreground",
)}
>
<Icon className="h-4 w-4" />
</span>
<span className="min-w-0 flex-1">
<span className="flex min-w-0 items-baseline gap-2">
<span className="font-mono text-[13px] font-semibold text-foreground">
{command.command}
</span>
{command.argHint ? (
<span className="font-mono text-[12px] text-muted-foreground">
{command.argHint}
</span>
) : null}
<span className="truncate text-[13px] font-medium">
{title}
</span>
</span>
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
{description}
</span>
</span>
</button>
);
})}
</div>
<div className="flex items-center gap-2 px-2 pt-1.5 text-[10.5px] text-muted-foreground/70">
<span>{t("thread.composer.slash.navigateHint")}</span>
<span>{t("thread.composer.slash.selectHint")}</span>
<span>{t("thread.composer.slash.closeHint")}</span>
</div>
</div>
);
}
interface AttachmentChipProps {
image: AttachedImage;
labelRemove: string;

View File

@ -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<SlashCommand[]>([]);
const pendingFirstRef = useRef<string | null>(null);
const messageCacheRef = useRef<Map<string, UIMessage[]>>(new Map());
const lastCachedChatIdRef = useRef<string | null>(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}
/>
) : (
<ThreadComposer

View File

@ -127,6 +127,51 @@
"deepResearch": "Deep research",
"voice": "Voice input"
},
"slash": {
"ariaLabel": "Slash commands",
"label": "commands",
"navigateHint": "↑↓ Navigate",
"selectHint": "Enter/Tab Select",
"closeHint": "Esc Close",
"commands": {
"new": {
"title": "New chat",
"description": "Stop the current task and start a fresh conversation."
},
"stop": {
"title": "Stop current task",
"description": "Cancel the active agent turn for this chat."
},
"restart": {
"title": "Restart nanobot",
"description": "Restart the bot process in place."
},
"status": {
"title": "Show status",
"description": "Display runtime, provider, and channel status."
},
"history": {
"title": "Show conversation history",
"description": "Print the last N persisted conversation messages."
},
"dream": {
"title": "Run Dream",
"description": "Manually trigger memory consolidation."
},
"dream_log": {
"title": "Show Dream log",
"description": "Show what the last Dream consolidation changed."
},
"dream_restore": {
"title": "Restore memory",
"description": "Revert memory to a previous Dream snapshot."
},
"help": {
"title": "Show help",
"description": "List available slash commands."
}
}
},
"encoding": "Encoding…",
"remove": "Remove attachment",
"normalizedSizeHint": "{{orig}} → {{current}} (auto)",

View File

@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "Cargando conversación…",
"empty": {
"description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo."
"description": "Haz preguntas, continúa tu trabajo local o inicia un nuevo hilo.",
"greeting": "¿Qué puedo hacer por ti?",
"quickActions": {
"plan": {
"title": "Crear un plan de proyecto",
"prompt": "Crea un plan de proyecto conciso para lo que debería construir después."
},
"analyze": {
"title": "Analizar estos datos",
"prompt": "Ayúdame a analizar estos datos y destaca los patrones más importantes."
},
"brainstorm": {
"title": "Lluvia de ideas",
"prompt": "Propón algunas ideas prácticas y sus compensaciones para este problema."
},
"code": {
"title": "Escribir código",
"prompt": "Ayúdame a escribir el código para esta tarea, empezando por el cambio útil más pequeño."
},
"summarize": {
"title": "Resumir este documento",
"prompt": "Resume este documento y enumera las conclusiones clave."
},
"more": {
"title": "Más",
"prompt": "Muéstrame algunas formas útiles en las que puedes ayudar en este workspace."
}
}
},
"header": {
"toggleSidebar": "Mostrar u ocultar la barra lateral"
@ -77,6 +104,51 @@
"decode_failed": "No se pudo decodificar esta imagen",
"too_large": "Imagen demasiado grande — prueba una más pequeña",
"io": "No se pudo leer este archivo"
},
"slash": {
"ariaLabel": "Comandos slash",
"label": "comandos",
"navigateHint": "↑↓ Navegar",
"selectHint": "Enter/Tab Insertar",
"closeHint": "Esc Cerrar",
"commands": {
"new": {
"title": "Nuevo chat",
"description": "Detiene la tarea actual e inicia una conversación nueva."
},
"stop": {
"title": "Detener tarea actual",
"description": "Cancela el turno activo del agent en este chat."
},
"restart": {
"title": "Reiniciar nanobot",
"description": "Reinicia el proceso del bot en el mismo lugar."
},
"status": {
"title": "Mostrar estado",
"description": "Muestra el estado del runtime, provider y channels."
},
"history": {
"title": "Mostrar historial",
"description": "Imprime los últimos N mensajes persistidos de la conversación."
},
"dream": {
"title": "Ejecutar Dream",
"description": "Activa manualmente la consolidación de memoria."
},
"dream_log": {
"title": "Mostrar registro de Dream",
"description": "Muestra qué cambió la última consolidación Dream."
},
"dream_restore": {
"title": "Restaurar memoria",
"description": "Revierte la memoria a una instantánea Dream anterior."
},
"help": {
"title": "Mostrar ayuda",
"description": "Lista los comandos slash disponibles."
}
}
}
},
"scrollToBottom": "Desplazarse al final"

View File

@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "Chargement de la conversation…",
"empty": {
"description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil."
"description": "Posez des questions, poursuivez votre travail local ou démarrez un nouveau fil.",
"greeting": "Que puis-je faire pour vous ?",
"quickActions": {
"plan": {
"title": "Créer un plan de projet",
"prompt": "Créez un plan de projet concis pour ce que je devrais construire ensuite."
},
"analyze": {
"title": "Analyser ces données",
"prompt": "Aidez-moi à analyser ces données et à faire ressortir les tendances les plus importantes."
},
"brainstorm": {
"title": "Trouver des idées",
"prompt": "Proposez quelques idées pratiques et leurs compromis pour ce problème."
},
"code": {
"title": "Écrire du code",
"prompt": "Aidez-moi à écrire le code pour cette tâche, en commençant par le plus petit changement utile."
},
"summarize": {
"title": "Résumer ce document",
"prompt": "Résumez ce document et listez les points clés à retenir."
},
"more": {
"title": "Plus",
"prompt": "Montrez-moi quelques façons utiles dont vous pouvez maider dans cet espace de travail."
}
}
},
"header": {
"toggleSidebar": "Afficher ou masquer la barre latérale"
@ -77,6 +104,51 @@
"decode_failed": "Impossible de décoder cette image",
"too_large": "Image trop grande — essayez-en une plus petite",
"io": "Impossible de lire ce fichier"
},
"slash": {
"ariaLabel": "Commandes slash",
"label": "commandes",
"navigateHint": "↑↓ Naviguer",
"selectHint": "Entrée/Tab Insérer",
"closeHint": "Échap Fermer",
"commands": {
"new": {
"title": "Nouvelle discussion",
"description": "Arrêter la tâche en cours et démarrer une nouvelle conversation."
},
"stop": {
"title": "Arrêter la tâche en cours",
"description": "Annuler le tour agent actif pour cette discussion."
},
"restart": {
"title": "Redémarrer nanobot",
"description": "Redémarrer le processus du bot sur place."
},
"status": {
"title": "Afficher létat",
"description": "Afficher létat du runtime, du provider et des channels."
},
"history": {
"title": "Afficher lhistorique",
"description": "Afficher les N derniers messages persistés de la conversation."
},
"dream": {
"title": "Lancer Dream",
"description": "Déclencher manuellement la consolidation de la mémoire."
},
"dream_log": {
"title": "Afficher le journal Dream",
"description": "Afficher ce que la dernière consolidation Dream a changé."
},
"dream_restore": {
"title": "Restaurer la mémoire",
"description": "Revenir à un instantané Dream précédent."
},
"help": {
"title": "Afficher laide",
"description": "Lister les commandes slash disponibles."
}
}
}
},
"scrollToBottom": "Faire défiler vers le bas"

View File

@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "Memuat percakapan…",
"empty": {
"description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru."
"description": "Ajukan pertanyaan, lanjutkan pekerjaan lokal, atau mulai thread baru.",
"greeting": "Apa yang bisa saya bantu?",
"quickActions": {
"plan": {
"title": "Buat rencana proyek",
"prompt": "Buat rencana proyek ringkas untuk apa yang sebaiknya saya bangun berikutnya."
},
"analyze": {
"title": "Analisis data ini",
"prompt": "Bantu saya menganalisis data ini dan soroti pola yang paling penting."
},
"brainstorm": {
"title": "Brainstorm ide",
"prompt": "Brainstorm beberapa ide praktis dan tradeoff untuk masalah ini."
},
"code": {
"title": "Tulis kode",
"prompt": "Bantu saya menulis kode untuk tugas ini, mulai dari perubahan berguna yang paling kecil."
},
"summarize": {
"title": "Ringkas dokumen ini",
"prompt": "Ringkas dokumen ini dan daftar poin-poin utamanya."
},
"more": {
"title": "Lainnya",
"prompt": "Tunjukkan beberapa cara berguna Anda dapat membantu di workspace ini."
}
}
},
"header": {
"toggleSidebar": "Tampilkan atau sembunyikan sidebar"
@ -77,6 +104,51 @@
"decode_failed": "Tidak dapat mendekode gambar ini",
"too_large": "Gambar terlalu besar — coba yang lebih kecil",
"io": "Tidak dapat membaca file ini"
},
"slash": {
"ariaLabel": "Perintah slash",
"label": "perintah",
"navigateHint": "↑↓ Pilih",
"selectHint": "Enter/Tab Sisipkan",
"closeHint": "Esc Tutup",
"commands": {
"new": {
"title": "Obrolan baru",
"description": "Hentikan tugas saat ini dan mulai percakapan baru."
},
"stop": {
"title": "Hentikan tugas saat ini",
"description": "Batalkan giliran agent yang sedang aktif di chat ini."
},
"restart": {
"title": "Mulai ulang nanobot",
"description": "Mulai ulang proses bot di tempat."
},
"status": {
"title": "Tampilkan status",
"description": "Tampilkan status runtime, provider, dan channel."
},
"history": {
"title": "Tampilkan riwayat",
"description": "Cetak N pesan percakapan tersimpan terbaru."
},
"dream": {
"title": "Jalankan Dream",
"description": "Picu konsolidasi memori secara manual."
},
"dream_log": {
"title": "Tampilkan log Dream",
"description": "Tampilkan perubahan dari konsolidasi Dream terakhir."
},
"dream_restore": {
"title": "Pulihkan memori",
"description": "Kembalikan memori ke snapshot Dream sebelumnya."
},
"help": {
"title": "Tampilkan bantuan",
"description": "Daftar perintah slash yang tersedia."
}
}
}
},
"scrollToBottom": "Gulir ke bawah"

View File

@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "会話を読み込み中…",
"empty": {
"description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。"
"description": "質問したり、ローカル作業を続けたり、新しいスレッドを始めたりできます。",
"greeting": "何をお手伝いしましょうか?",
"quickActions": {
"plan": {
"title": "プロジェクト計画を作成",
"prompt": "次に作るものについて、簡潔なプロジェクト計画を作成してください。"
},
"analyze": {
"title": "このデータを分析",
"prompt": "このデータを分析し、最も重要なパターンを指摘してください。"
},
"brainstorm": {
"title": "アイデアを出す",
"prompt": "この問題について、実用的なアイデアとトレードオフをいくつか出してください。"
},
"code": {
"title": "コードを書く",
"prompt": "このタスクのコードを書くのを手伝ってください。まず最小限の有用な変更から始めてください。"
},
"summarize": {
"title": "この文書を要約",
"prompt": "この文書を要約し、重要なポイントを列挙してください。"
},
"more": {
"title": "その他",
"prompt": "このワークスペースであなたが手伝える便利な方法をいくつか見せてください。"
}
}
},
"header": {
"toggleSidebar": "サイドバーを切り替える"
@ -77,6 +104,51 @@
"decode_failed": "この画像をデコードできません",
"too_large": "画像が大きすぎます。小さいものを選んでください",
"io": "このファイルを読み込めません"
},
"slash": {
"ariaLabel": "スラッシュコマンド",
"label": "コマンド",
"navigateHint": "↑↓ 選択",
"selectHint": "Enter/Tab 入力",
"closeHint": "Esc 閉じる",
"commands": {
"new": {
"title": "新しいチャット",
"description": "現在のタスクを停止して、新しい会話を開始します。"
},
"stop": {
"title": "現在のタスクを停止",
"description": "このチャットで実行中の agent ターンをキャンセルします。"
},
"restart": {
"title": "nanobot を再起動",
"description": "bot プロセスをその場で再起動します。"
},
"status": {
"title": "ステータスを表示",
"description": "ランタイム、provider、channel の状態を表示します。"
},
"history": {
"title": "会話履歴を表示",
"description": "保存済みの直近 N 件の会話メッセージを表示します。"
},
"dream": {
"title": "Dream を実行",
"description": "メモリ統合を手動で開始します。"
},
"dream_log": {
"title": "Dream ログを表示",
"description": "直近の Dream 統合で変更された内容を表示します。"
},
"dream_restore": {
"title": "メモリを復元",
"description": "以前の Dream スナップショットへメモリを戻します。"
},
"help": {
"title": "ヘルプを表示",
"description": "利用可能なスラッシュコマンドを一覧表示します。"
}
}
}
},
"scrollToBottom": "一番下へスクロール"

View File

@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "대화 불러오는 중…",
"empty": {
"description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다."
"description": "질문을 하거나, 로컬 작업을 이어가거나, 새 스레드를 시작할 수 있습니다.",
"greeting": "무엇을 도와드릴까요?",
"quickActions": {
"plan": {
"title": "프로젝트 계획 만들기",
"prompt": "다음에 만들 것에 대한 간결한 프로젝트 계획을 작성해 주세요."
},
"analyze": {
"title": "이 데이터 분석하기",
"prompt": "이 데이터를 분석하고 가장 중요한 패턴을 짚어 주세요."
},
"brainstorm": {
"title": "아이디어 브레인스토밍",
"prompt": "이 문제에 대한 실용적인 아이디어와 트레이드오프를 몇 가지 제안해 주세요."
},
"code": {
"title": "코드 작성하기",
"prompt": "이 작업을 위한 코드를 작성해 주세요. 가장 작은 유용한 변경부터 시작해 주세요."
},
"summarize": {
"title": "문서 요약하기",
"prompt": "이 문서를 요약하고 핵심 내용을 정리해 주세요."
},
"more": {
"title": "더 보기",
"prompt": "이 워크스페이스에서 도와줄 수 있는 유용한 방법을 몇 가지 보여 주세요."
}
}
},
"header": {
"toggleSidebar": "사이드바 전환"
@ -77,6 +104,51 @@
"decode_failed": "이 이미지를 디코딩할 수 없습니다",
"too_large": "이미지가 너무 큽니다. 더 작은 걸로 시도해 주세요",
"io": "이 파일을 읽을 수 없습니다"
},
"slash": {
"ariaLabel": "슬래시 명령",
"label": "명령",
"navigateHint": "↑↓ 선택",
"selectHint": "Enter/Tab 입력",
"closeHint": "Esc 닫기",
"commands": {
"new": {
"title": "새 채팅",
"description": "현재 작업을 중지하고 새 대화를 시작합니다."
},
"stop": {
"title": "현재 작업 중지",
"description": "이 채팅에서 실행 중인 agent 턴을 취소합니다."
},
"restart": {
"title": "nanobot 재시작",
"description": "bot 프로세스를 제자리에서 재시작합니다."
},
"status": {
"title": "상태 보기",
"description": "런타임, provider, channel 상태를 표시합니다."
},
"history": {
"title": "대화 기록 보기",
"description": "저장된 최근 N개의 대화 메시지를 출력합니다."
},
"dream": {
"title": "Dream 실행",
"description": "메모리 정리를 수동으로 트리거합니다."
},
"dream_log": {
"title": "Dream 로그 보기",
"description": "마지막 Dream 정리에서 변경된 내용을 표시합니다."
},
"dream_restore": {
"title": "메모리 복원",
"description": "이전 Dream 스냅샷으로 메모리를 되돌립니다."
},
"help": {
"title": "도움말 보기",
"description": "사용 가능한 슬래시 명령을 나열합니다."
}
}
}
},
"scrollToBottom": "맨 아래로 스크롤"

View File

@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "Đang tải cuộc trò chuyện…",
"empty": {
"description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới."
"description": "Hãy đặt câu hỏi, tiếp tục công việc cục bộ hoặc bắt đầu một luồng mới.",
"greeting": "Tôi có thể giúp gì cho bạn?",
"quickActions": {
"plan": {
"title": "Tạo kế hoạch dự án",
"prompt": "Tạo một kế hoạch dự án ngắn gọn cho việc tôi nên xây dựng tiếp theo."
},
"analyze": {
"title": "Phân tích dữ liệu này",
"prompt": "Giúp tôi phân tích dữ liệu này và chỉ ra các mẫu quan trọng nhất."
},
"brainstorm": {
"title": "Động não ý tưởng",
"prompt": "Động não vài ý tưởng thực tế và các đánh đổi cho vấn đề này."
},
"code": {
"title": "Viết mã",
"prompt": "Giúp tôi viết mã cho nhiệm vụ này, bắt đầu từ thay đổi hữu ích nhỏ nhất."
},
"summarize": {
"title": "Tóm tắt tài liệu này",
"prompt": "Tóm tắt tài liệu này và liệt kê các ý chính."
},
"more": {
"title": "Thêm",
"prompt": "Cho tôi xem vài cách hữu ích mà bạn có thể giúp trong workspace này."
}
}
},
"header": {
"toggleSidebar": "Bật/tắt thanh bên"
@ -77,6 +104,51 @@
"decode_failed": "Không thể giải mã ảnh này",
"too_large": "Ảnh quá lớn — hãy thử ảnh nhỏ hơn",
"io": "Không thể đọc tệp này"
},
"slash": {
"ariaLabel": "Lệnh slash",
"label": "lệnh",
"navigateHint": "↑↓ Chọn",
"selectHint": "Enter/Tab Chèn",
"closeHint": "Esc Đóng",
"commands": {
"new": {
"title": "Cuộc trò chuyện mới",
"description": "Dừng tác vụ hiện tại và bắt đầu một cuộc trò chuyện mới."
},
"stop": {
"title": "Dừng tác vụ hiện tại",
"description": "Hủy lượt agent đang chạy trong cuộc trò chuyện này."
},
"restart": {
"title": "Khởi động lại nanobot",
"description": "Khởi động lại tiến trình bot tại chỗ."
},
"status": {
"title": "Hiển thị trạng thái",
"description": "Hiển thị trạng thái runtime, provider và channel."
},
"history": {
"title": "Hiển thị lịch sử",
"description": "In N tin nhắn hội thoại đã lưu gần nhất."
},
"dream": {
"title": "Chạy Dream",
"description": "Kích hoạt thủ công quá trình hợp nhất bộ nhớ."
},
"dream_log": {
"title": "Hiển thị nhật ký Dream",
"description": "Hiển thị những gì lần hợp nhất Dream gần nhất đã thay đổi."
},
"dream_restore": {
"title": "Khôi phục bộ nhớ",
"description": "Đưa bộ nhớ về một snapshot Dream trước đó."
},
"help": {
"title": "Hiển thị trợ giúp",
"description": "Liệt kê các lệnh slash có sẵn."
}
}
}
},
"scrollToBottom": "Cuộn xuống cuối"

View File

@ -115,6 +115,51 @@
"deepResearch": "深度研究",
"voice": "语音输入"
},
"slash": {
"ariaLabel": "斜杠命令",
"label": "命令",
"navigateHint": "↑↓ 选择",
"selectHint": "Enter/Tab 填入",
"closeHint": "Esc 关闭",
"commands": {
"new": {
"title": "新建对话",
"description": "停止当前任务,并开始一个新的对话。"
},
"stop": {
"title": "停止当前任务",
"description": "取消这个对话中正在运行的 agent 回合。"
},
"restart": {
"title": "重启 nanobot",
"description": "原地重启 bot 进程。"
},
"status": {
"title": "查看状态",
"description": "显示运行时、provider 和 channel 状态。"
},
"history": {
"title": "查看对话历史",
"description": "打印最近 N 条已持久化的对话消息。"
},
"dream": {
"title": "运行 Dream",
"description": "手动触发记忆整理。"
},
"dream_log": {
"title": "查看 Dream 日志",
"description": "查看上一次 Dream 整理改变了什么。"
},
"dream_restore": {
"title": "恢复记忆",
"description": "将记忆恢复到之前的 Dream 快照。"
},
"help": {
"title": "查看帮助",
"description": "列出可用的斜杠命令。"
}
}
},
"encoding": "处理中…",
"remove": "移除附件",
"normalizedSizeHint": "{{orig}} → {{current}}(已自动压缩)",

View File

@ -53,7 +53,34 @@
"thread": {
"loadingConversation": "正在載入對話…",
"empty": {
"description": "你可以提問、延續本地工作,或是開始新的執行緒。"
"description": "你可以提問、延續本地工作,或是開始新的執行緒。",
"greeting": "我可以幫你做什麼?",
"quickActions": {
"plan": {
"title": "建立專案計畫",
"prompt": "幫我為接下來要做的事情寫一份簡潔的專案計畫。"
},
"analyze": {
"title": "分析這些資料",
"prompt": "幫我分析這些資料,並指出最重要的模式。"
},
"brainstorm": {
"title": "腦力激盪想法",
"prompt": "圍繞這個問題腦力激盪幾個實用方案,並說明取捨。"
},
"code": {
"title": "撰寫程式碼",
"prompt": "幫我為這個任務撰寫程式碼,先從最小可用改動開始。"
},
"summarize": {
"title": "總結這份文件",
"prompt": "幫我總結這份文件,並列出關鍵重點。"
},
"more": {
"title": "更多",
"prompt": "展示幾個你在這個工作區裡可以幫我的實用方式。"
}
}
},
"header": {
"toggleSidebar": "切換側邊欄"
@ -77,6 +104,51 @@
"decode_failed": "無法解碼這張圖片",
"too_large": "圖片太大,請換一張小一點的",
"io": "無法讀取這個檔案"
},
"slash": {
"ariaLabel": "斜線命令",
"label": "命令",
"navigateHint": "↑↓ 選擇",
"selectHint": "Enter/Tab 填入",
"closeHint": "Esc 關閉",
"commands": {
"new": {
"title": "新增對話",
"description": "停止目前任務,並開始新的對話。"
},
"stop": {
"title": "停止目前任務",
"description": "取消這個對話中正在執行的 agent 回合。"
},
"restart": {
"title": "重新啟動 nanobot",
"description": "原地重新啟動 bot 進程。"
},
"status": {
"title": "查看狀態",
"description": "顯示執行環境、provider 和 channel 狀態。"
},
"history": {
"title": "查看對話歷史",
"description": "列印最近 N 則已持久化的對話訊息。"
},
"dream": {
"title": "執行 Dream",
"description": "手動觸發記憶整理。"
},
"dream_log": {
"title": "查看 Dream 日誌",
"description": "查看上一次 Dream 整理變更了什麼。"
},
"dream_restore": {
"title": "恢復記憶",
"description": "將記憶恢復到之前的 Dream 快照。"
},
"help": {
"title": "查看說明",
"description": "列出可用的斜線命令。"
}
}
}
},
"scrollToBottom": "捲動到底部"

View File

@ -1,4 +1,4 @@
import type { ChatSummary, SettingsPayload, SettingsUpdate } from "./types";
import type { ChatSummary, SettingsPayload, SettingsUpdate, SlashCommand } from "./types";
export class ApiError extends Error {
status: number;
@ -114,6 +114,27 @@ export async function fetchSettings(
return request<SettingsPayload>(`${base}/api/settings`, token);
}
export async function listSlashCommands(
token: string,
base: string = "",
): Promise<SlashCommand[]> {
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,

View File

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

View File

@ -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" },
}),
);
});
});

View File

@ -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();
}
}
});
});

View File

@ -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(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
slashCommands={COMMANDS}
/>,
);
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();
});
});

View File

@ -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,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
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,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
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,
<ThreadShell
session={session("chat-a")}
title="Chat chat-a"
onToggleSidebar={() => {}}
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,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
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" });
});