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.
This commit is contained in:
chengyongru
2026-07-07 15:42:04 +08:00
committed by Xubin Ren
parent 8f68040f05
commit ef5318ebdc
2 changed files with 75 additions and 2 deletions
+30 -2
View File
@@ -90,8 +90,37 @@ import { cn } from "@/lib/utils";
const ACCEPT_ATTR = "image/png,image/jpeg,image/webp,image/gif"; const ACCEPT_ATTR = "image/png,image/jpeg,image/webp,image/gif";
const VOICE_SHORTCUT_CODE = "KeyD"; const VOICE_SHORTCUT_CODE = "KeyD";
const VOICE_SHORTCUT_ARIA = "Control+Shift+D"; 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"; 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 { function formatBytes(n: number): string {
if (n < 1024) return `${n} B`; if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
@@ -1480,12 +1509,11 @@ export function ThreadComposer({
...(attachedMcpPresets.length > 0 ? { mcpPresets: attachedMcpPresets } : {}), ...(attachedMcpPresets.length > 0 ? { mcpPresets: attachedMcpPresets } : {}),
} }
: undefined; : undefined;
const commandName = content.split(/\s+/, 1)[0];
const isSlashSideChannel = const isSlashSideChannel =
payload === undefined payload === undefined
&& attachedCliApps.length === 0 && attachedCliApps.length === 0
&& attachedMcpPresets.length === 0 && attachedMcpPresets.length === 0
&& visibleSlashCommands.some((command) => command.command === commandName); && isSlashCommandSideChannel(content, visibleSlashCommands);
onSend( onSend(
content, content,
payload, payload,
+45
View File
@@ -1329,6 +1329,51 @@ describe("ThreadComposer", () => {
expect(onSend).toHaveBeenCalledWith("/history", undefined, { sideChannel: true }); 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(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
/>,
);
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(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
slashCommands={[
{
command: "/goal",
title: "Start long-running goal",
description: "Tell the agent to treat the request as a long-running goal.",
icon: "activity",
argHint: "<goal>",
},
]}
/>,
);
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", () => { it("shows a stop button while streaming", () => {
const onStop = vi.fn(); const onStop = vi.fn();
render( render(