fix(webui): finalize turn-ending slash commands

maintainer edit: keep /new and manually submitted /stop from leaving stale WebUI streaming state after they cancel or reset the active turn.
This commit is contained in:
chengyongru
2026-07-07 15:42:04 +08:00
committed by Xubin Ren
parent ef5318ebdc
commit 8a231b6e4d
4 changed files with 152 additions and 7 deletions
+42 -4
View File
@@ -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,
+12 -3
View File
@@ -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(() => {
+41
View File
@@ -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(
<ThreadComposer
onSend={onSend}
placeholder="Type your message..."
/>,
);
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(
<ThreadComposer
onSend={onSend}
onStop={onStop}
isStreaming
placeholder="Type your message..."
/>,
);
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(
+57
View File
@@ -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 {