fix(control-ui): create sessions for typed /new

Route typed Control UI `/new` through the dashboard session create-and-switch flow used by the New Chat button.

Keep typed `/reset` as the explicit in-place gateway reset path, and document the Control UI slash-command boundary.

Fixes #69599.
This commit is contained in:
Val Alexander
2026-05-02 04:02:34 -05:00
committed by GitHub
parent 6669827135
commit 37aebf612b
6 changed files with 90 additions and 54 deletions

View File

@@ -469,9 +469,72 @@ describe("handleSendChat", () => {
expect(host.refreshSessionsAfterChat.size).toBe(0);
});
it("sends button-triggered /new resets after confirmation", async () => {
it("runs the fresh-session action for confirmed /new overrides", async () => {
const confirm = vi.fn(() => true);
vi.stubGlobal("confirm", confirm);
const request = vi.fn(async (method: string) => {
throw new Error(`Unexpected request: ${method}`);
});
const onSlashAction = vi.fn();
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
chatMessage: "restore me",
sessionKey: "agent:main",
onSlashAction,
});
await handleSendChat(host, "/new", { confirmReset: true, restoreDraft: true });
expect(confirm).toHaveBeenCalledTimes(1);
expect(request).not.toHaveBeenCalled();
expect(onSlashAction).toHaveBeenCalledWith("new-session");
expect(host.chatMessage).toBe("restore me");
expect(host.refreshSessionsAfterChat.size).toBe(0);
});
it("routes typed /new through the fresh-session action without confirmation", async () => {
const confirm = vi.fn(() => false);
vi.stubGlobal("confirm", confirm);
const request = vi.fn(async (method: string) => {
throw new Error(`Unexpected request: ${method}`);
});
const onSlashAction = vi.fn();
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
chatMessage: "/new",
sessionKey: "agent:main",
onSlashAction,
});
await handleSendChat(host);
expect(confirm).not.toHaveBeenCalled();
expect(request).not.toHaveBeenCalled();
expect(onSlashAction).toHaveBeenCalledWith("new-session");
expect(host.chatMessage).toBe("");
});
it("does not queue typed /new behind an active run", async () => {
const onSlashAction = vi.fn();
const host = makeHost({
chatMessage: "/new",
chatRunId: "run-main",
chatStream: "Working...",
onSlashAction,
});
await handleSendChat(host);
expect(onSlashAction).toHaveBeenCalledWith("new-session");
expect(host.chatQueue).toEqual([]);
expect(host.chatRunId).toBe("run-main");
expect(host.chatStream).toBe("Working...");
expect(host.chatMessage).toBe("");
});
it("preserves typed /reset command dispatch without confirmation", async () => {
const confirm = vi.fn(() => false);
vi.stubGlobal("confirm", confirm);
const request = vi.fn(async (method: string) => {
if (method === "chat.send") {
return { status: "started" };
@@ -480,57 +543,23 @@ describe("handleSendChat", () => {
});
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
chatMessage: "restore me",
chatMessage: "/reset",
sessionKey: "agent:main",
});
await handleSendChat(host, "/new", { confirmReset: true, restoreDraft: true });
await handleSendChat(host);
expect(confirm).toHaveBeenCalledTimes(1);
expect(confirm).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
sessionKey: "agent:main",
message: "/new",
deliver: false,
idempotencyKey: expect.any(String),
message: "/reset",
}),
);
expect(host.chatMessage).toBe("restore me");
expect(host.refreshSessionsAfterChat).toContain(host.chatRunId);
expect(host.chatMessage).toBe("");
});
it.each(["/new", "/reset"])(
"preserves typed %s command dispatch without confirmation",
async (command) => {
const confirm = vi.fn(() => false);
vi.stubGlobal("confirm", confirm);
const request = vi.fn(async (method: string) => {
if (method === "chat.send") {
return { status: "started" };
}
throw new Error(`Unexpected request: ${method}`);
});
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
chatMessage: command,
sessionKey: "agent:main",
});
await handleSendChat(host);
expect(confirm).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
sessionKey: "agent:main",
message: command,
}),
);
expect(host.chatMessage).toBe("");
},
);
it("keeps slash-command model changes in sync with the chat header cache", async () => {
vi.stubGlobal(
"fetch",

View File

@@ -69,7 +69,7 @@ export type ChatHost = ChatInputHistoryState & {
pendingAbort?: { runId: string; sessionKey: string } | null;
chatSubmitGuards?: Map<string, Promise<void>>;
/** Callback for slash-command side effects that need app-level access. */
onSlashAction?: (action: string) => void;
onSlashAction?: (action: string) => void | Promise<void>;
};
export type ChatSendOptions = {
@@ -527,7 +527,7 @@ export async function handleSendChat(
}
function shouldQueueLocalSlashCommand(name: string): boolean {
return !["stop", "focus", "export-session", "steer", "redirect"].includes(name);
return !["stop", "focus", "export-session", "steer", "redirect", "new"].includes(name);
}
// ── Slash Command Dispatch ──
@@ -543,11 +543,11 @@ async function dispatchSlashCommand(
await handleAbortChat(host);
return;
case "new":
await sendChatMessageNow(host, "/new", {
refreshSessions: true,
previousDraft: sendOpts?.previousDraft,
restoreDraft: sendOpts?.restoreDraft,
});
if (!host.onSlashAction) {
host.lastError = "New Chat is unavailable.";
return;
}
await host.onSlashAction("new-session");
return;
case "reset":
await sendChatMessageNow(host, "/reset", {
@@ -560,10 +560,10 @@ async function dispatchSlashCommand(
await clearChatHistory(host);
return;
case "focus":
host.onSlashAction?.("toggle-focus");
await host.onSlashAction?.("toggle-focus");
return;
case "export-session":
host.onSlashAction?.("export");
await host.onSlashAction?.("export");
return;
}
@@ -596,7 +596,7 @@ async function dispatchSlashCommand(
...host.chatModelOverrides,
[targetSessionKey]: result.sessionPatch.modelOverride ?? null,
};
host.onSlashAction?.("refresh-tools-effective");
await host.onSlashAction?.("refresh-tools-effective");
}
if (result.action === "refresh") {

View File

@@ -34,6 +34,7 @@ import {
handleFirstUpdated,
handleUpdated,
} from "./app-lifecycle.ts";
import { createChatSession as createChatSessionInternal } from "./app-render.helpers.ts";
import { renderApp } from "./app-render.ts";
import {
exportLogs as exportLogsInternal,
@@ -225,7 +226,7 @@ export class OpenClawApp extends LitElement {
private chatMobileControlsTrigger: HTMLElement | null = null;
@state() navDrawerOpen = false;
onSlashAction?: (action: string) => void;
onSlashAction?: (action: string) => void | Promise<void>;
chatLocalInputHistoryBySession: Record<string, Array<{ text: string; ts: number }>> = {};
chatInputHistorySessionKey: string | null = null;
chatInputHistoryItems: string[] | null = null;
@@ -605,8 +606,11 @@ export class OpenClawApp extends LitElement {
connectedCallback() {
super.connectedCallback();
this.onSlashAction = (action: string) => {
this.onSlashAction = async (action: string) => {
switch (action) {
case "new-session":
await createChatSessionInternal(this as unknown as AppViewState);
break;
case "toggle-focus":
this.applySettings({
...this.settings,
@@ -617,7 +621,7 @@ export class OpenClawApp extends LitElement {
exportChatMarkdown(this.chatMessages, this.assistantName);
break;
case "refresh-tools-effective": {
void refreshVisibleToolsEffectiveForCurrentSessionInternal(this);
await refreshVisibleToolsEffectiveForCurrentSessionInternal(this);
break;
}
}