From ef5318ebdc6c0c18b59b9525b5f0835608d17fe3 Mon Sep 17 00:00:00 2001 From: chengyongru Date: Mon, 6 Jul 2026 14:22:26 +0800 Subject: [PATCH] fix(webui): classify builtin slash commands without metadata maintainer edit: keep builtin shortcut commands on the side-channel path before async command metadata loads, while preserving /goal task text as a normal agent turn. --- .../src/components/thread/ThreadComposer.tsx | 32 ++++++++++++- webui/src/tests/thread-composer.test.tsx | 45 +++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index e2967f2b..e1d7d0a4 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -90,8 +90,37 @@ import { cn } from "@/lib/utils"; const ACCEPT_ATTR = "image/png,image/jpeg,image/webp,image/gif"; const VOICE_SHORTCUT_CODE = "KeyD"; const VOICE_SHORTCUT_ARIA = "Control+Shift+D"; +const FALLBACK_SIDE_CHANNEL_COMMANDS = new Set([ + "/new", + "/stop", + "/restart", + "/status", + "/model", + "/history", + "/goal", + "/trigger", + "/dream", + "/dream-log", + "/dream-restore", + "/dream-prompt", + "/skill", + "/help", + "/pairing", +]); type VoiceShortcutPlatform = "apple" | "chromeos" | "linux" | "other" | "windows"; +function isSlashCommandSideChannel(content: string, visibleSlashCommands: SlashCommand[]): boolean { + const commandName = content.split(/\s+/, 1)[0]; + if (!commandName.startsWith("/")) return false; + if (commandName === "/goal" && content.slice(commandName.length).trim().length > 0) { + return false; + } + return ( + FALLBACK_SIDE_CHANNEL_COMMANDS.has(commandName) + || visibleSlashCommands.some((command) => command.command === commandName) + ); +} + function formatBytes(n: number): string { if (n < 1024) return `${n} B`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; @@ -1480,12 +1509,11 @@ export function ThreadComposer({ ...(attachedMcpPresets.length > 0 ? { mcpPresets: attachedMcpPresets } : {}), } : undefined; - const commandName = content.split(/\s+/, 1)[0]; const isSlashSideChannel = payload === undefined && attachedCliApps.length === 0 && attachedMcpPresets.length === 0 - && visibleSlashCommands.some((command) => command.command === commandName); + && isSlashCommandSideChannel(content, visibleSlashCommands); onSend( content, payload, diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx index f4d27039..b0459e32 100644 --- a/webui/src/tests/thread-composer.test.tsx +++ b/webui/src/tests/thread-composer.test.tsx @@ -1329,6 +1329,51 @@ describe("ThreadComposer", () => { expect(onSend).toHaveBeenCalledWith("/history", undefined, { sideChannel: true }); }); + it("marks builtin slash commands as side-channel sends before command metadata loads", () => { + const onSend = vi.fn(); + render( + , + ); + + const input = screen.getByLabelText("Message input"); + fireEvent.change(input, { target: { value: "/status" } }); + fireEvent.click(screen.getByRole("button", { name: "Send message" })); + + expect(onSend).toHaveBeenCalledWith("/status", undefined, { sideChannel: true }); + }); + + it("keeps goal task commands on the normal agent turn path", () => { + const onSend = vi.fn(); + render( + ", + }, + ]} + />, + ); + + const input = screen.getByLabelText("Message input"); + fireEvent.change(input, { target: { value: "/goal fix the release blocker" } }); + fireEvent.click(screen.getByRole("button", { name: "Send message" })); + + expect(onSend).toHaveBeenCalledWith( + "/goal fix the release blocker", + undefined, + undefined, + ); + }); + it("shows a stop button while streaming", () => { const onStop = vi.fn(); render(