From 92915ea4240fec6d0797af3336e2849c898d7dd4 Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Sun, 24 May 2026 20:56:07 +0800 Subject: [PATCH] feat(webui): improve slash command actions --- .../src/components/thread/ThreadComposer.tsx | 193 ++++++++++++++---- webui/src/components/thread/ThreadShell.tsx | 39 +++- webui/src/i18n/locales/en/common.json | 18 ++ webui/src/i18n/locales/es/common.json | 18 ++ webui/src/i18n/locales/fr/common.json | 18 ++ webui/src/i18n/locales/id/common.json | 18 ++ webui/src/i18n/locales/ja/common.json | 18 ++ webui/src/i18n/locales/ko/common.json | 18 ++ webui/src/i18n/locales/vi/common.json | 18 ++ webui/src/i18n/locales/zh-CN/common.json | 18 ++ webui/src/i18n/locales/zh-TW/common.json | 18 ++ webui/src/tests/i18n.test.tsx | 81 ++++++++ webui/src/tests/thread-composer.test.tsx | 75 +++++++ webui/src/tests/thread-shell.test.tsx | 78 +++++++ 14 files changed, 576 insertions(+), 52 deletions(-) diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index 4f4b2ec28..c33707ef8 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -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 = { 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("auto"); const [aspectMenuOpen, setAspectMenuOpen] = useState(false); + const [recentSlashCommands, setRecentSlashCommands] = useState(() => readSlashRecents()); const textareaRef = useRef(null); const formRef = useRef(null); const fileInputRef = useRef(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(() => { 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(() => { @@ -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]", )} > -
- {t("thread.composer.slash.label")} -
{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]", )} > - - - - {command.command} - - {command.argHint ? ( - - {command.argHint} - - ) : null} - - {title} - + + + {title} - - {description} + + {command.detail || description} + + + + {command.badge || command.recent ? ( + + {command.badge ?? t("thread.composer.slash.badges.recent")} + + ) : null} + + {command.argHint ? `${command.command} ${command.argHint}` : command.command} ); })}
-
- {t("thread.composer.slash.navigateHint")} - {t("thread.composer.slash.selectHint")} - {t("thread.composer.slash.closeHint")} -
); } diff --git a/webui/src/components/thread/ThreadShell.tsx b/webui/src/components/thread/ThreadShell.tsx index e6b444c06..2eeb1d13d 100644 --- a/webui/src/components/thread/ThreadShell.tsx +++ b/webui/src/components/thread/ThreadShell.tsx @@ -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]); diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index ec9525bfa..c5e98f0d0 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -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." } } }, diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index dd26d2141..84a135329 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -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…", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index 37503a491..1a443f6d8 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -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 l’historique", "description": "Afficher les N derniers messages persistés de la conversation." @@ -574,7 +578,21 @@ "help": { "title": "Afficher l’aide", "description": "Lister les commandes slash disponibles." + }, + "pairing": { + "title": "Appairage", + "description": "Gérer les demandes d’appairage." } + }, + "badges": { + "current": "Actuel", + "recent": "Récent" + }, + "details": { + "goalActive": "L’objectif est en cours", + "goalReady": "Démarrer un objectif durable", + "history": "Afficher les messages récents", + "stopRunning": "En cours" } }, "encoding": "Traitement…", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 2fb331f31..4b64b7af7 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -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…", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 6bd159d99..4ddcf1a0b 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -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": "処理中…", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 11a54d09f..6e6470cfa 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -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": "처리 중…", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index e20f48c65..a98bcbbc7 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -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ý…", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index db4a9ef26..83b78feb6 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -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": "管理配对请求。" } } }, diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index eea26fc0c..f59bea6a3 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -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": "處理中…", diff --git a/webui/src/tests/i18n.test.tsx b/webui/src/tests/i18n.test.tsx index 07f7d195b..9f228c961 100644 --- a/webui/src/tests/i18n.test.tsx +++ b/webui/src/tests/i18n.test.tsx @@ -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 { + return !!value && typeof value === "object" && !Array.isArray(value); +} + +function flattenResource(value: unknown, prefix = ""): Map { + const out = new Map(); + 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; diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index 250b28308..6433055eb 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + 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( diff --git a/webui/src/tests/thread-shell.test.tsx b/webui/src/tests/thread-shell.test.tsx index e9e0e5646..d9624983d 100644 --- a/webui/src/tests/thread-shell.test.tsx +++ b/webui/src/tests/thread-shell.test.tsx @@ -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 }) => 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, + {}} + 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, + {}} + 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");