diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx
index e1d7d0a4..b0e48f18 100644
--- a/webui/src/components/thread/ThreadComposer.tsx
+++ b/webui/src/components/thread/ThreadComposer.tsx
@@ -109,8 +109,21 @@ const FALLBACK_SIDE_CHANNEL_COMMANDS = new Set([
]);
type VoiceShortcutPlatform = "apple" | "chromeos" | "linux" | "other" | "windows";
+function slashCommandName(content: string): string {
+ return content.split(/\s+/, 1)[0];
+}
+
+function isExactSlashCommand(content: string, commandName: string): boolean {
+ if (slashCommandName(content) !== commandName) return false;
+ return content.slice(commandName.length).trim().length === 0;
+}
+
+function shouldFinalizeActiveTurn(content: string): boolean {
+ return isExactSlashCommand(content, "/new");
+}
+
function isSlashCommandSideChannel(content: string, visibleSlashCommands: SlashCommand[]): boolean {
- const commandName = content.split(/\s+/, 1)[0];
+ const commandName = slashCommandName(content);
if (!commandName.startsWith("/")) return false;
if (commandName === "/goal" && content.slice(commandName.length).trim().length > 0) {
return false;
@@ -1509,15 +1522,37 @@ export function ThreadComposer({
...(attachedMcpPresets.length > 0 ? { mcpPresets: attachedMcpPresets } : {}),
}
: undefined;
- const isSlashSideChannel =
+ const hasPlainTextCommandPayload =
payload === undefined
&& attachedCliApps.length === 0
- && attachedMcpPresets.length === 0
+ && attachedMcpPresets.length === 0;
+ if (
+ hasPlainTextCommandPayload
+ && isStreaming
+ && onStop
+ && isExactSlashCommand(content, "/stop")
+ ) {
+ handleStop();
+ setQueuedPrompts([]);
+ clear();
+ clearComposerText();
+ return;
+ }
+ const isSlashSideChannel =
+ hasPlainTextCommandPayload
&& isSlashCommandSideChannel(content, visibleSlashCommands);
+ const finalizeActiveTurn =
+ isSlashSideChannel && shouldFinalizeActiveTurn(content);
onSend(
content,
payload,
- isSlashSideChannel ? { ...options, sideChannel: true } : options,
+ isSlashSideChannel
+ ? {
+ ...options,
+ sideChannel: true,
+ ...(finalizeActiveTurn ? { finalizeActiveTurn } : {}),
+ }
+ : options,
);
setQueuedPrompts([]);
// Bubble owns the data URL copy; safe to revoke every staged blob
@@ -1530,9 +1565,12 @@ export function ThreadComposer({
canSend,
clear,
clearComposerText,
+ handleStop,
+ isStreaming,
modelNeedsSetup,
onModelBadgeClick,
onSend,
+ onStop,
readyImages,
value,
visibleSlashCommands,
diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts
index 6df6d53b..55c83e03 100644
--- a/webui/src/hooks/useNanobotStream.ts
+++ b/webui/src/hooks/useNanobotStream.ts
@@ -448,6 +448,7 @@ export interface SendOptions {
mcpPresets?: OutboundMcpPresetMention[];
workspaceScope?: WorkspaceScopePayload | null;
sideChannel?: boolean;
+ finalizeActiveTurn?: boolean;
}
function eventExtendsModelActivity(ev: InboundEvent): boolean {
@@ -1111,19 +1112,26 @@ export function useNanobotStream(
if (!hasImages && !content.trim()) return;
const sideChannel = options?.sideChannel === true;
+ const finalizeActiveTurn = options?.finalizeActiveTurn === true;
flushPendingStreamEvents();
+ if (finalizeActiveTurn) {
+ cancelStreamEndTimer();
+ setIsStreaming(false);
+ }
const turnId = crypto.randomUUID();
if (sideChannel) sideChannelTurnIdsRef.current.add(turnId);
const previews = hasImages ? images!.map((i) => i.preview) : undefined;
setMessages((prev) => {
- if (!sideChannel) {
+ if (!sideChannel || finalizeActiveTurn) {
buffer.current = null;
activeAssistantRef.current = null;
closedAssistantStreamIdsRef.current.clear();
clearActivitySegment();
+ suppressStreamUntilTurnEndRef.current = false;
}
+ const base = finalizeActiveTurn ? finalizeStreamedTurn(prev) : prev;
return [
- ...(sideChannel ? prev : pruneReasoningOnlyPlaceholders(prev)),
+ ...(sideChannel ? base : pruneReasoningOnlyPlaceholders(base)),
{
id: crypto.randomUUID(),
role: "user",
@@ -1142,9 +1150,10 @@ export function useNanobotStream(
const wireMedia = hasImages ? images!.map((i) => i.media) : undefined;
const wireOptions = { ...options, turnId };
delete wireOptions.sideChannel;
+ delete wireOptions.finalizeActiveTurn;
client.sendMessage(chatId, content, wireMedia, wireOptions);
},
- [chatId, clearActivitySegment, client, flushPendingStreamEvents],
+ [cancelStreamEndTimer, chatId, clearActivitySegment, client, flushPendingStreamEvents],
);
const stop = useCallback(() => {
diff --git a/webui/src/tests/thread-composer.test.tsx b/webui/src/tests/thread-composer.test.tsx
index b0459e32..c86b3f59 100644
--- a/webui/src/tests/thread-composer.test.tsx
+++ b/webui/src/tests/thread-composer.test.tsx
@@ -1345,6 +1345,47 @@ describe("ThreadComposer", () => {
expect(onSend).toHaveBeenCalledWith("/status", undefined, { sideChannel: true });
});
+ it("marks new chat commands as side-channel sends that finalize the active turn", () => {
+ const onSend = vi.fn();
+ render(
+ ,
+ );
+
+ const input = screen.getByLabelText("Message input");
+ fireEvent.change(input, { target: { value: "/new" } });
+ fireEvent.click(screen.getByRole("button", { name: "Send message" }));
+
+ expect(onSend).toHaveBeenCalledWith(
+ "/new",
+ undefined,
+ { sideChannel: true, finalizeActiveTurn: true },
+ );
+ });
+
+ it("routes a manually submitted stop command through the stop handler", () => {
+ const onSend = vi.fn();
+ const onStop = vi.fn();
+ render(
+ ,
+ );
+
+ const input = screen.getByLabelText("Message input");
+ fireEvent.change(input, { target: { value: "/stop" } });
+ fireEvent.keyDown(input, { key: "Escape" });
+ fireEvent.keyDown(input, { key: "Enter" });
+
+ expect(onStop).toHaveBeenCalledTimes(1);
+ expect(onSend).not.toHaveBeenCalled();
+ });
+
it("keeps goal task commands on the normal agent turn path", () => {
const onSend = vi.fn();
render(
diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx
index 1d44021f..97f46da8 100644
--- a/webui/src/tests/useNanobotStream.test.tsx
+++ b/webui/src/tests/useNanobotStream.test.tsx
@@ -1653,6 +1653,63 @@ describe("useNanobotStream", () => {
]);
});
+ it("finalizes active streaming before turn-ending side-channel commands", async () => {
+ const fake = fakeClient();
+ const { result } = renderHook(() => useNanobotStream("chat-new", EMPTY_MESSAGES), {
+ wrapper: wrap(fake.client),
+ });
+
+ act(() => {
+ result.current.send("long task");
+ });
+ const activeTurnId = fake.client.sendMessage.mock.calls.at(-1)![3]?.turnId;
+
+ act(() => {
+ fake.emit("chat-new", {
+ event: "delta",
+ chat_id: "chat-new",
+ text: "partial answer",
+ turn_id: activeTurnId,
+ });
+ });
+ await flushStreamFrame();
+
+ expect(result.current.isStreaming).toBe(true);
+ expect(result.current.messages.find((message) => message.content === "partial answer"))
+ .toMatchObject({ isStreaming: true });
+
+ act(() => {
+ result.current.send("/new", undefined, {
+ sideChannel: true,
+ finalizeActiveTurn: true,
+ });
+ });
+
+ const newCall = fake.client.sendMessage.mock.calls.at(-1)!;
+ expect(newCall[3]).not.toHaveProperty("sideChannel");
+ expect(newCall[3]).not.toHaveProperty("finalizeActiveTurn");
+ expect(result.current.isStreaming).toBe(false);
+ expect(result.current.messages.find((message) => message.content === "partial answer"))
+ .toMatchObject({ isStreaming: false });
+
+ act(() => {
+ fake.emit("chat-new", {
+ event: "message",
+ chat_id: "chat-new",
+ text: "New session started.",
+ turn_id: newCall[3]?.turnId,
+ });
+ });
+
+ expect(result.current.isStreaming).toBe(false);
+ expect(result.current.messages.map((message) => message.content)).toEqual([
+ "long task",
+ "partial answer",
+ "/new",
+ "New session started.",
+ ]);
+ });
+
it("lets stream_end finish streaming while side-channel status replies arrive", () => {
vi.useFakeTimers();
try {