feat(webui): improve slash command actions

This commit is contained in:
Xubin Ren 2026-05-24 20:56:07 +08:00
parent 3f0098839e
commit 92915ea424
14 changed files with 576 additions and 52 deletions

View File

@ -21,6 +21,7 @@ import {
Activity,
ArrowUp,
BookOpen,
Brain,
Check,
ChevronDown,
ChevronUp,
@ -30,6 +31,7 @@ import {
Loader2,
Plus,
RotateCw,
Shield,
Sparkles,
Square,
SquarePen,
@ -98,9 +100,11 @@ interface ThreadComposerProps {
const COMMAND_ICONS: Record<string, LucideIcon> = {
activity: Activity,
"book-open": BookOpen,
brain: Brain,
"circle-help": CircleHelp,
history: History,
"rotate-cw": RotateCw,
shield: Shield,
sparkles: Sparkles,
square: Square,
"square-pen": SquarePen,
@ -113,7 +117,9 @@ const IMAGE_ASPECT_RATIOS: ImageAspectRatio[] = ["auto", "1:1", "3:4", "9:16", "
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 = 40;
const SLASH_PALETTE_CHROME_PX = 12;
const SLASH_RECENTS_STORAGE_KEY = "nanobot.webui.slashCommandRecents";
const SLASH_RECENTS_LIMIT = 5;
type SlashPalettePlacement = "above" | "below";
@ -132,10 +138,41 @@ type MentionCandidate =
| { kind: "cli"; name: string; app: CliAppInfo }
| { kind: "mcp"; name: string; preset: McpPresetInfo };
interface SlashPaletteCommand extends SlashCommand {
detail: string;
badge?: string;
recent: boolean;
}
function slashCommandI18nKey(command: string): string {
return command.replace(/^\//, "").replace(/-/g, "_");
}
function readSlashRecents(): string[] {
if (typeof window === "undefined") return [];
try {
const raw = window.localStorage.getItem(SLASH_RECENTS_STORAGE_KEY);
const parsed = raw ? JSON.parse(raw) : [];
return Array.isArray(parsed)
? parsed.filter((item): item is string => typeof item === "string").slice(0, SLASH_RECENTS_LIMIT)
: [];
} catch {
return [];
}
}
function storeSlashRecents(commands: string[]): void {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
SLASH_RECENTS_STORAGE_KEY,
JSON.stringify(commands.slice(0, SLASH_RECENTS_LIMIT)),
);
} catch {
// localStorage may be unavailable in private contexts; command insertion still works.
}
}
function scrollNearestOverflowParent(target: EventTarget | null, deltaY: number) {
if (!(target instanceof Element) || deltaY === 0) return;
let el: HTMLElement | null = target.parentElement;
@ -450,6 +487,7 @@ export function ThreadComposer({
const [uncontrolledImageMode, setUncontrolledImageMode] = useState(false);
const [imageAspectRatio, setImageAspectRatio] = useState<ImageAspectRatio>("auto");
const [aspectMenuOpen, setAspectMenuOpen] = useState(false);
const [recentSlashCommands, setRecentSlashCommands] = useState<string[]>(() => readSlashRecents());
const textareaRef = useRef<HTMLTextAreaElement>(null);
const formRef = useRef<HTMLFormElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@ -534,26 +572,86 @@ export function ThreadComposer({
return commandToken.toLowerCase();
}, [disabled, slashMenuDismissed, value]);
const filteredSlashCommands = useMemo(() => {
const visibleSlashCommands = useMemo(() => {
if (!(isStreaming && onStop)) return slashCommands;
if (slashCommands.some((command) => command.command === "/stop")) return slashCommands;
return [
{
command: "/stop",
title: "Stop current task",
description: "Cancel the active agent turn for this chat.",
icon: "square",
},
...slashCommands,
];
}, [isStreaming, onStop, slashCommands]);
const filteredSlashCommands = useMemo<SlashPaletteCommand[]>(() => {
if (slashQuery === null) return [];
return slashCommands
const withDetails = visibleSlashCommands
.filter((command) => {
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,
});
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: "",
}),
title,
description,
].join(" ").toLowerCase();
return haystack.includes(slashQuery);
})
.map((command) => {
const commandKey = slashCommandI18nKey(command.command);
const description = t(`thread.composer.slash.commands.${commandKey}.description`, {
defaultValue: command.description,
});
let detail = description;
let badge: string | undefined;
if (command.command === "/model" && modelLabel) {
detail = modelLabel;
badge = t("thread.composer.slash.badges.current");
} else if (command.command === "/goal") {
detail = goalState?.active
? t("thread.composer.slash.details.goalActive")
: t("thread.composer.slash.details.goalReady");
} else if (command.command === "/stop" && isStreaming) {
detail = t("thread.composer.slash.details.stopRunning");
} else if (command.command === "/history") {
detail = t("thread.composer.slash.details.history");
}
return {
...command,
detail,
badge,
recent: recentSlashCommands.includes(command.command),
};
})
.sort((a, b) => {
if (isStreaming) {
if (a.command === "/stop") return -1;
if (b.command === "/stop") return 1;
}
if (slashQuery !== "") return 0;
const aRecent = recentSlashCommands.indexOf(a.command);
const bRecent = recentSlashCommands.indexOf(b.command);
if (aRecent !== -1 || bRecent !== -1) {
if (aRecent === -1) return 1;
if (bRecent === -1) return -1;
return aRecent - bRecent;
}
return 0;
});
return withDetails
.slice(0, 8);
}, [slashCommands, slashQuery, t]);
}, [goalState?.active, isStreaming, modelLabel, recentSlashCommands, slashQuery, t, visibleSlashCommands]);
const showSlashMenu = filteredSlashCommands.length > 0;
const cliAppMention = useMemo<CliAppMentionQuery | null>(() => {
@ -746,13 +844,30 @@ export function ThreadComposer({
const chooseSlashCommand = useCallback(
(command: SlashCommand) => {
const nextRecents = [
command.command,
...recentSlashCommands.filter((item) => item !== command.command),
].slice(0, SLASH_RECENTS_LIMIT);
setRecentSlashCommands(nextRecents);
storeSlashRecents(nextRecents);
if (command.command === "/stop" && isStreaming && onStop) {
onStop();
setValue("");
setSlashMenuDismissed(true);
setCliAppMenuDismissed(false);
setInlineError(null);
resizeTextarea();
return;
}
setValue(command.argHint ? `${command.command} ` : command.command);
setSlashMenuDismissed(true);
setCliAppMenuDismissed(false);
setInlineError(null);
resizeTextarea();
},
[resizeTextarea],
[isStreaming, onStop, recentSlashCommands, resizeTextarea],
);
const chooseMentionCandidate = useCallback(
@ -1307,12 +1422,12 @@ function ComposerCliMentionOverlay({
);
}
interface SlashCommandPaletteProps {
commands: SlashCommand[];
commands: SlashPaletteCommand[];
selectedIndex: number;
layout: SlashPaletteLayout;
isHero: boolean;
onHover: (index: number) => void;
onChoose: (command: SlashCommand) => void;
onChoose: (command: SlashPaletteCommand) => void;
}
interface CliAppMentionPaletteProps {
@ -1532,14 +1647,11 @@ function SlashCommandPalette({
className={cn(
"absolute left-1/2 z-30 w-[calc(100%-0.5rem)] -translate-x-1/2 overflow-hidden rounded-[18px] border",
layout.placement === "above" ? "bottom-full mb-2" : "top-full mt-2",
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.18)]",
"border-border/65 bg-popover p-1.5 text-popover-foreground shadow-[0_18px_55px_rgba(15,23,42,0.16)]",
"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="overflow-y-auto pr-0.5" style={{ maxHeight: listMaxHeight }}>
{commands.map((command, index) => {
const Icon = COMMAND_ICONS[command.icon] ?? CircleHelp;
@ -1563,49 +1675,42 @@ function SlashCommandPalette({
onChoose(command);
}}
className={cn(
"flex w-full items-center gap-3 rounded-[13px] px-3 py-2.5 text-left transition-colors",
"flex min-h-[44px] w-full items-center gap-3 rounded-[13px] px-3 py-2 text-left transition-colors",
selected
? "bg-primary/10 text-foreground"
: "text-foreground/86 hover:bg-accent/55",
? "bg-foreground/[0.065] text-foreground dark:bg-white/[0.09]"
: "text-foreground/86 hover:bg-foreground/[0.045] dark:hover:bg-white/[0.065]",
)}
>
<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",
"flex h-7 w-7 shrink-0 items-center justify-center text-muted-foreground transition-colors",
selected && "text-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 className="flex min-w-0 flex-1 items-baseline gap-2">
<span className="min-w-0 truncate text-[13.5px] font-semibold tracking-normal text-foreground">
{title}
</span>
<span className="mt-0.5 block truncate text-[12px] text-muted-foreground">
{description}
<span className="min-w-0 truncate text-[13px] text-muted-foreground">
{command.detail || description}
</span>
</span>
<span className="ml-2 flex shrink-0 items-center gap-1.5">
{command.badge || command.recent ? (
<span className="hidden rounded-full bg-foreground/[0.055] px-2 py-1 text-[11px] font-medium text-muted-foreground sm:inline-flex">
{command.badge ?? t("thread.composer.slash.badges.recent")}
</span>
) : null}
<span className="font-mono text-[12px] text-muted-foreground/60">
{command.argHint ? `${command.command} ${command.argHint}` : command.command}
</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>
);
}

View File

@ -40,6 +40,20 @@ function projectWebuiThreadMessages(messages: UIMessage[]): UIMessage[] {
return scrubSubagentUiMessages(normalizeLegacyLongTaskMessages(messages));
}
function sameMessageShape(a: UIMessage, b: UIMessage): boolean {
return (
a.role === b.role
&& (a.kind ?? "") === (b.kind ?? "")
&& a.content === b.content
);
}
function isStaleThreadSnapshot(current: UIMessage[], snapshot: UIMessage[]): boolean {
if (current.length === 0 || snapshot.length >= current.length) return false;
if (snapshot.length === 0) return true;
return snapshot.every((message, index) => sameMessageShape(current[index], message));
}
interface ThreadShellProps {
session: ChatSummary | null;
title: string;
@ -219,19 +233,28 @@ export function ThreadShell({
// canonical replay arrives (e.g. after ``session_updated`` refresh), prefer it
// so rendering converges to the same shape as a manual refresh.
setMessages((prev) => {
const normalizedHistory = projectWebuiThreadMessages(historical);
const keepLiveMessages = (messagesToKeep: UIMessage[]) => {
const projected = projectWebuiThreadMessages(messagesToKeep);
messageCacheRef.current.set(chatId, projected);
return projected;
};
if (hasNewCanonicalHistory && historical.length > 0) {
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
pendingCanonicalHydrateRef.current.delete(chatId);
appliedHistoryVersionRef.current.set(chatId, historyVersion);
const normalized = projectWebuiThreadMessages(historical);
messageCacheRef.current.set(chatId, normalized);
return normalized;
messageCacheRef.current.set(chatId, normalizedHistory);
return normalizedHistory;
}
if (cached && cached.length > 0) return projectWebuiThreadMessages(cached);
if (historical.length === 0 && prev.length > 0) return projectWebuiThreadMessages(prev);
if (cached && cached.length > 0) {
const normalizedCached = projectWebuiThreadMessages(cached);
if (isStaleThreadSnapshot(prev, normalizedCached)) return keepLiveMessages(prev);
return normalizedCached;
}
if (isStaleThreadSnapshot(prev, normalizedHistory)) return keepLiveMessages(prev);
appliedHistoryVersionRef.current.set(chatId, historyVersion);
const next = projectWebuiThreadMessages(historical);
if (historical.length > 0) messageCacheRef.current.set(chatId, next);
return next;
if (normalizedHistory.length > 0) messageCacheRef.current.set(chatId, normalizedHistory);
return normalizedHistory;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading, chatId, historical, historyVersion]);

View File

@ -534,6 +534,16 @@
"navigateHint": "↑↓ Navigate",
"selectHint": "Enter/Tab Select",
"closeHint": "Esc Close",
"badges": {
"current": "Current",
"recent": "Recent"
},
"details": {
"goalActive": "Goal is running",
"goalReady": "Start a sustained objective",
"history": "Show recent messages",
"stopRunning": "Running now"
},
"commands": {
"new": {
"title": "New chat",
@ -551,6 +561,10 @@
"title": "Show status",
"description": "Display runtime, provider, and channel status."
},
"model": {
"title": "Model",
"description": "Show or switch the active model preset."
},
"history": {
"title": "Show conversation history",
"description": "Print the last N persisted conversation messages."
@ -574,6 +588,10 @@
"help": {
"title": "Show help",
"description": "List available slash commands."
},
"pairing": {
"title": "Pairing",
"description": "Manage pairing requests."
}
}
},

View File

@ -551,6 +551,10 @@
"title": "Mostrar estado",
"description": "Muestra el estado del runtime, provider y channels."
},
"model": {
"title": "Modelo",
"description": "Muestra o cambia el preset de modelo activo."
},
"history": {
"title": "Mostrar historial",
"description": "Imprime los últimos N mensajes persistidos de la conversación."
@ -574,7 +578,21 @@
"help": {
"title": "Mostrar ayuda",
"description": "Lista los comandos slash disponibles."
},
"pairing": {
"title": "Emparejamiento",
"description": "Gestiona solicitudes de emparejamiento."
}
},
"badges": {
"current": "Actual",
"recent": "Reciente"
},
"details": {
"goalActive": "El objetivo está en curso",
"goalReady": "Iniciar un objetivo sostenido",
"history": "Mostrar mensajes recientes",
"stopRunning": "En ejecución"
}
},
"encoding": "Procesando…",

View File

@ -551,6 +551,10 @@
"title": "Afficher létat",
"description": "Afficher létat du runtime, du provider et des channels."
},
"model": {
"title": "Modèle",
"description": "Afficher ou changer le préréglage de modèle actif."
},
"history": {
"title": "Afficher lhistorique",
"description": "Afficher les N derniers messages persistés de la conversation."
@ -574,7 +578,21 @@
"help": {
"title": "Afficher laide",
"description": "Lister les commandes slash disponibles."
},
"pairing": {
"title": "Appairage",
"description": "Gérer les demandes dappairage."
}
},
"badges": {
"current": "Actuel",
"recent": "Récent"
},
"details": {
"goalActive": "Lobjectif est en cours",
"goalReady": "Démarrer un objectif durable",
"history": "Afficher les messages récents",
"stopRunning": "En cours"
}
},
"encoding": "Traitement…",

View File

@ -551,6 +551,10 @@
"title": "Tampilkan status",
"description": "Tampilkan status runtime, provider, dan channel."
},
"model": {
"title": "Model",
"description": "Tampilkan atau ganti preset model aktif."
},
"history": {
"title": "Tampilkan riwayat",
"description": "Cetak N pesan percakapan tersimpan terbaru."
@ -574,7 +578,21 @@
"help": {
"title": "Tampilkan bantuan",
"description": "Daftar perintah slash yang tersedia."
},
"pairing": {
"title": "Pemasangan",
"description": "Kelola permintaan pemasangan."
}
},
"badges": {
"current": "Saat ini",
"recent": "Terbaru"
},
"details": {
"goalActive": "Tujuan sedang berjalan",
"goalReady": "Mulai tujuan berkelanjutan",
"history": "Tampilkan pesan terbaru",
"stopRunning": "Sedang berjalan"
}
},
"encoding": "Memproses…",

View File

@ -551,6 +551,10 @@
"title": "ステータスを表示",
"description": "ランタイム、provider、channel の状態を表示します。"
},
"model": {
"title": "モデル",
"description": "有効なモデルプリセットを表示または切り替えます。"
},
"history": {
"title": "会話履歴を表示",
"description": "保存済みの直近 N 件の会話メッセージを表示します。"
@ -574,7 +578,21 @@
"help": {
"title": "ヘルプを表示",
"description": "利用可能なスラッシュコマンドを一覧表示します。"
},
"pairing": {
"title": "ペアリング",
"description": "ペアリングリクエストを管理します。"
}
},
"badges": {
"current": "現在",
"recent": "最近"
},
"details": {
"goalActive": "目標が実行中",
"goalReady": "継続的な目標を開始",
"history": "最近のメッセージを表示",
"stopRunning": "実行中"
}
},
"encoding": "処理中…",

View File

@ -551,6 +551,10 @@
"title": "상태 보기",
"description": "런타임, provider, channel 상태를 표시합니다."
},
"model": {
"title": "모델",
"description": "활성 모델 프리셋을 보거나 전환합니다."
},
"history": {
"title": "대화 기록 보기",
"description": "저장된 최근 N개의 대화 메시지를 출력합니다."
@ -574,7 +578,21 @@
"help": {
"title": "도움말 보기",
"description": "사용 가능한 슬래시 명령을 나열합니다."
},
"pairing": {
"title": "페어링",
"description": "페어링 요청을 관리합니다."
}
},
"badges": {
"current": "현재",
"recent": "최근"
},
"details": {
"goalActive": "목표 실행 중",
"goalReady": "지속 목표 시작",
"history": "최근 메시지 보기",
"stopRunning": "실행 중"
}
},
"encoding": "처리 중…",

View File

@ -551,6 +551,10 @@
"title": "Hiển thị trạng thái",
"description": "Hiển thị trạng thái runtime, provider và channel."
},
"model": {
"title": "Mô hình",
"description": "Hiển thị hoặc chuyển preset mô hình đang hoạt động."
},
"history": {
"title": "Hiển thị lịch sử",
"description": "In N tin nhắn hội thoại đã lưu gần nhất."
@ -574,7 +578,21 @@
"help": {
"title": "Hiển thị trợ giúp",
"description": "Liệt kê các lệnh slash có sẵn."
},
"pairing": {
"title": "Ghép nối",
"description": "Quản lý yêu cầu ghép nối."
}
},
"badges": {
"current": "Hiện tại",
"recent": "Gần đây"
},
"details": {
"goalActive": "Mục tiêu đang chạy",
"goalReady": "Bắt đầu mục tiêu duy trì",
"history": "Hiển thị tin nhắn gần đây",
"stopRunning": "Đang chạy"
}
},
"encoding": "Đang xử lý…",

View File

@ -533,6 +533,16 @@
"navigateHint": "↑↓ 选择",
"selectHint": "Enter/Tab 填入",
"closeHint": "Esc 关闭",
"badges": {
"current": "当前",
"recent": "最近"
},
"details": {
"goalActive": "目标正在运行",
"goalReady": "开始一个持续目标",
"history": "查看最近消息",
"stopRunning": "正在运行"
},
"commands": {
"new": {
"title": "新建对话",
@ -550,6 +560,10 @@
"title": "查看状态",
"description": "显示运行时、服务商和通道状态。"
},
"model": {
"title": "模型",
"description": "查看或切换当前模型预设。"
},
"history": {
"title": "查看对话历史",
"description": "打印最近 N 条已持久化的对话消息。"
@ -573,6 +587,10 @@
"help": {
"title": "查看帮助",
"description": "列出可用的斜杠命令。"
},
"pairing": {
"title": "配对",
"description": "管理配对请求。"
}
}
},

View File

@ -551,6 +551,10 @@
"title": "查看狀態",
"description": "顯示執行環境、provider 和 channel 狀態。"
},
"model": {
"title": "模型",
"description": "查看或切換目前模型預設。"
},
"history": {
"title": "查看對話歷史",
"description": "列印最近 N 則已持久化的對話訊息。"
@ -574,7 +578,21 @@
"help": {
"title": "查看說明",
"description": "列出可用的斜線命令。"
},
"pairing": {
"title": "配對",
"description": "管理配對請求。"
}
},
"badges": {
"current": "目前",
"recent": "最近"
},
"details": {
"goalActive": "目標正在執行",
"goalReady": "開始一個持續目標",
"history": "查看最近訊息",
"stopRunning": "正在執行"
}
},
"encoding": "處理中…",

View File

@ -8,6 +8,20 @@ import { resources } from "@/i18n";
const QUICK_ACTION_KEYS = ["plan", "analyze", "brainstorm", "code", "summarize", "more"];
const IMAGE_QUICK_ACTION_KEYS = ["icon", "sticker", "poster", "product", "portrait", "edit"];
const SLASH_COMMAND_KEYS = [
"new",
"stop",
"restart",
"status",
"model",
"history",
"dream",
"dream_log",
"dream_restore",
"goal",
"help",
"pairing",
];
const SETTINGS_NAV_KEYS = [
"overview",
"appearance",
@ -18,6 +32,33 @@ const SETTINGS_NAV_KEYS = [
"advanced",
];
function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function flattenResource(value: unknown, prefix = ""): Map<string, unknown> {
const out = new Map<string, unknown>();
if (!isRecord(value)) return out;
for (const [key, child] of Object.entries(value)) {
const path = prefix ? `${prefix}.${key}` : key;
if (isRecord(child)) {
for (const [childPath, childValue] of flattenResource(child, path)) {
out.set(childPath, childValue);
}
} else {
out.set(path, child);
}
}
return out;
}
function interpolationKeys(value: unknown): string[] {
if (typeof value !== "string") return [];
return Array.from(value.matchAll(/{{\s*([\w.-]+)\s*}}/g))
.map((match) => match[1])
.sort();
}
describe("webui i18n", () => {
it("switches UI copy and document locale through the language switcher", async () => {
const user = userEvent.setup();
@ -72,6 +113,46 @@ describe("webui i18n", () => {
}
});
it("keeps every locale aligned with the English resource shape", () => {
const reference = flattenResource(resources.en.common);
for (const [locale, resource] of Object.entries(resources)) {
if (locale === "en") continue;
const current = flattenResource(resource.common);
const missing = Array.from(reference.keys()).filter((key) => !current.has(key));
const extra = Array.from(current.keys()).filter((key) => !reference.has(key));
const interpolationMismatches = Array.from(reference.entries())
.filter(([key]) => current.has(key))
.filter(([key, value]) =>
interpolationKeys(value).join(",") !== interpolationKeys(current.get(key)).join(",")
)
.map(([key]) => key);
expect({ locale, missing, extra, interpolationMismatches }).toEqual({
locale,
missing: [],
extra: [],
interpolationMismatches: [],
});
}
});
it("keeps slash commands localized for every registered locale", () => {
for (const resource of Object.values(resources)) {
const slash = resource.common.thread.composer.slash;
expect(slash.badges.current).toBeTruthy();
expect(slash.badges.recent).toBeTruthy();
expect(slash.details.goalActive).toBeTruthy();
expect(slash.details.goalReady).toBeTruthy();
expect(slash.details.history).toBeTruthy();
expect(slash.details.stopRunning).toBeTruthy();
for (const key of SLASH_COMMAND_KEYS) {
const command = slash.commands[key as keyof typeof slash.commands];
expect(command.title).toBeTruthy();
expect(command.description).toBeTruthy();
}
}
});
it("keeps settings navigation localized for every registered locale", () => {
for (const resource of Object.values(resources)) {
const common = resource.common;

View File

@ -115,6 +115,7 @@ const ORIGINAL_INNER_HEIGHT = window.innerHeight;
afterEach(() => {
vi.restoreAllMocks();
window.localStorage.clear();
Object.defineProperty(window, "innerHeight", {
value: ORIGINAL_INNER_HEIGHT,
configurable: true,
@ -258,6 +259,80 @@ describe("ThreadComposer", () => {
expect(screen.queryByRole("listbox", { name: "Slash commands" })).not.toBeInTheDocument();
});
it("renders slash commands as direct actions with current status", () => {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
modelLabel="deepseek-v4-pro"
slashCommands={[
{
command: "/model",
title: "Switch model preset",
description: "Show or switch the active model preset.",
icon: "brain",
argHint: "[preset]",
},
COMMANDS[1],
]}
/>,
);
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "/" },
});
expect(screen.getByRole("option", { name: /Model deepseek-v4-pro/i })).toBeInTheDocument();
expect(screen.getByText("Current")).toBeInTheDocument();
expect(screen.getByText("/model [preset]")).toBeInTheDocument();
});
it("prioritizes stop as an immediate slash action while streaming", () => {
const onStop = vi.fn();
render(
<ThreadComposer
onSend={vi.fn()}
onStop={onStop}
isStreaming
placeholder="Type your message..."
slashCommands={[COMMANDS[1]]}
/>,
);
const input = screen.getByLabelText("Message input");
fireEvent.change(input, { target: { value: "/" } });
expect(screen.getByRole("option", { name: /Stop current task/i })).toHaveAttribute(
"aria-selected",
"true",
);
fireEvent.keyDown(input, { key: "Enter" });
expect(onStop).toHaveBeenCalledTimes(1);
expect(input).toHaveValue("");
});
it("orders recent slash commands first for the blank slash menu", () => {
window.localStorage.setItem("nanobot.webui.slashCommandRecents", JSON.stringify(["/history"]));
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
slashCommands={COMMANDS}
/>,
);
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "/" },
});
expect(screen.getByRole("option", { name: /\/history/i })).toHaveAttribute(
"aria-selected",
"true",
);
expect(screen.getByText("Recent")).toBeInTheDocument();
});
it("opens the CLI app mention palette and inserts the selected app", () => {
const onSend = vi.fn();
render(

View File

@ -340,6 +340,84 @@ describe("ThreadShell", () => {
expect(screen.queryByText("What can I do for you?")).not.toBeInTheDocument();
});
it("keeps a live first command reply when the initial history snapshot is stale", async () => {
const client = makeClient();
const onCreateChat = vi.fn().mockResolvedValue("chat-new");
let resolveThread:
| ((value: { ok: boolean; status: number; json: () => Promise<unknown> }) => void)
| null = null;
vi.stubGlobal(
"fetch",
vi.fn((input: RequestInfo | URL) => {
const url = String(input);
if (url.includes("websocket%3Achat-new/webui-thread")) {
return new Promise((resolve) => {
resolveThread = resolve;
});
}
return Promise.resolve({
ok: false,
status: 404,
json: async () => ({}),
});
}),
);
const { rerender } = render(
wrap(
client,
<ThreadShell
session={null}
title="nanobot"
onToggleSidebar={() => {}}
onCreateChat={onCreateChat}
/>,
),
);
fireEvent.change(screen.getByLabelText("Message input"), {
target: { value: "/model" },
});
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
await waitFor(() => expect(onCreateChat).toHaveBeenCalledTimes(1));
await act(async () => {
rerender(
wrap(
client,
<ThreadShell
session={session("chat-new")}
title="Chat chat-new"
onToggleSidebar={() => {}}
onCreateChat={onCreateChat}
/>,
),
);
});
await waitFor(() =>
expect(client.sendMessage).toHaveBeenCalledWith("chat-new", "/model", undefined),
);
await act(async () => {
client._emitChat("chat-new", {
event: "message",
chat_id: "chat-new",
text: "## Model\n- Current model: `Ring-2.6-1T`",
});
});
expect(screen.getByText(/Current model/)).toBeInTheDocument();
await act(async () => {
resolveThread?.(
httpJson(transcriptFromSimpleMessages([{ role: "user", content: "/model" }])),
);
});
await waitFor(() => expect(screen.getByText(/Current model/)).toBeInTheDocument());
});
it("sends quick action prompts from the empty thread landing", async () => {
const client = makeClient();
const onNewChat = vi.fn().mockResolvedValue("chat-a");