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(