fix(ui): confirm button-triggered new session resets (#73361)

This commit is contained in:
Vincent Koc
2026-04-28 02:10:33 -07:00
committed by GitHub
parent 62997f7fce
commit 1912e309f7
5 changed files with 129 additions and 3 deletions

View File

@@ -31,6 +31,7 @@ Docs: https://docs.openclaw.ai
- Agents/Anthropic: cancel stalled Anthropic Messages SSE body reads when abort signals fire, so active-memory timeouts release transport resources instead of leaving hidden recall runs parked on `reader.read()`. Refs #72965 and #73120. Thanks @wdeveloper16.
- Control UI/WebChat: keep pending run and typing state attached to the active client run, so unowned inject/announce/side-result finals no longer unlock unrelated active runs while completed owned runs still clear promptly. Fixes #57795; carries forward the narrow diagnosis from #57887. Thanks @haoyu-haoyu.
- Sandbox/Docker: stop satisfying a missing default sandbox image by tagging plain Debian as `openclaw-sandbox:bookworm-slim`, preserving the Python tooling required by sandbox write/edit helpers and directing users to build the default image. Fixes #51185; refs #45108, #51099, #51609, and #57713. Thanks @dpalis, @Tin55FoilDev, @jbcohen2-coder, @macminihal-cyber, and @PraxoOnline.
- Control UI/WebChat: confirm toolbar New Session button resets before dispatching `/new` while leaving typed `/new` and `/reset` commands immediate. Fixes #45800; refs #27065, #56611, #54499, and #27110. Thanks @aethnova, @kosta228-huli, @adambezemek, and @xss925175263 (xianshishan).
- Agents/models: keep per-agent primary models strict when `fallbacks` is omitted, so probe-only custom providers are not tried as hidden fallback candidates unless the agent explicitly opts in. Fixes #73332. Thanks @haumanto.
- Gateway/models: add `models.pricing.enabled` so offline or restricted-network installs can skip startup OpenRouter and LiteLLM pricing-catalog fetches while keeping explicit model costs working. Fixes #53639. Thanks @callebtc, @palewire, and @rjdjohnston.
- Onboarding: pin interactive and non-interactive health checks to the just-configured setup token/password so stale `OPENCLAW_GATEWAY_TOKEN` or `OPENCLAW_GATEWAY_PASSWORD` values do not produce false gateway-token-mismatch failures after setup. Fixes #72203. Thanks @galiniliev.

View File

@@ -427,6 +427,110 @@ describe("handleSendChat", () => {
vi.unstubAllGlobals();
});
it("cancels button-triggered /new resets when confirmation is declined", async () => {
const confirm = vi.fn(() => false);
vi.stubGlobal("confirm", confirm);
const request = vi.fn(async (method: string) => {
throw new Error(`Unexpected request: ${method}`);
});
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
chatMessage: "keep this draft",
sessionKey: "agent:main",
});
await handleSendChat(host, "/new", { confirmReset: true, restoreDraft: true });
expect(confirm).toHaveBeenCalledWith("Start a new session? This will reset the current chat.");
expect(request).not.toHaveBeenCalled();
expect(host.chatMessage).toBe("keep this draft");
expect(host.chatMessages).toEqual([]);
expect(host.chatRunId).toBeNull();
expect(host.refreshSessionsAfterChat.size).toBe(0);
});
it("cancels button-triggered /new resets when confirmation is unavailable", async () => {
vi.stubGlobal("confirm", undefined);
const request = vi.fn(async (method: string) => {
throw new Error(`Unexpected request: ${method}`);
});
const host = makeHost({
client: { request } as unknown as ChatHost["client"],
chatMessage: "keep this draft",
sessionKey: "agent:main",
});
await handleSendChat(host, "/new", { confirmReset: true, restoreDraft: true });
expect(request).not.toHaveBeenCalled();
expect(host.chatMessage).toBe("keep this draft");
expect(host.chatMessages).toEqual([]);
expect(host.chatRunId).toBeNull();
expect(host.refreshSessionsAfterChat.size).toBe(0);
});
it("sends button-triggered /new resets after confirmation", async () => {
const confirm = vi.fn(() => true);
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: "restore me",
sessionKey: "agent:main",
});
await handleSendChat(host, "/new", { confirmReset: true, restoreDraft: true });
expect(confirm).toHaveBeenCalledTimes(1);
expect(request).toHaveBeenCalledWith(
"chat.send",
expect.objectContaining({
sessionKey: "agent:main",
message: "/new",
deliver: false,
idempotencyKey: expect.any(String),
}),
);
expect(host.chatMessage).toBe("restore me");
expect(host.refreshSessionsAfterChat).toContain(host.chatRunId);
});
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

@@ -72,6 +72,11 @@ export type ChatHost = ChatInputHistoryState & {
onSlashAction?: (action: string) => void;
};
export type ChatSendOptions = {
confirmReset?: boolean;
restoreDraft?: boolean;
};
export const CHAT_SESSIONS_ACTIVE_MINUTES = 120;
export {
handleChatDraftChange,
@@ -115,6 +120,16 @@ function isChatResetCommand(text: string) {
return normalized.startsWith("/new ") || normalized.startsWith("/reset ");
}
function confirmChatResetCommand(text: string) {
if (!isChatResetCommand(text)) {
return true;
}
if (typeof globalThis.confirm !== "function") {
return false;
}
return globalThis.confirm("Start a new session? This will reset the current chat.");
}
function isBtwCommand(text: string) {
return /^\/btw(?::|\s|$)/i.test(text.trim());
}
@@ -408,7 +423,7 @@ export function clearPendingQueueItemsForRun(host: ChatHost, runId: string | und
export async function handleSendChat(
host: ChatHost,
messageOverride?: string,
opts?: { restoreDraft?: boolean },
opts?: ChatSendOptions,
) {
if (!host.connected) {
return;
@@ -423,6 +438,10 @@ export async function handleSendChat(
return;
}
if (messageOverride != null && opts?.confirmReset && !confirmChatResetCommand(message)) {
return;
}
if (isChatStopCommand(message)) {
if (messageOverride == null) {
recordNonTranscriptInputHistory(host, message);

View File

@@ -2357,7 +2357,8 @@ export function renderApp(state: AppViewState) {
onDismissSideResult: () => {
state.chatSideResult = null;
},
onNewSession: () => state.handleSendChat("/new", { restoreDraft: true }),
onNewSession: () =>
state.handleSendChat("/new", { confirmReset: true, restoreDraft: true }),
onClearHistory: async () => {
if (!state.client || !state.connected) {
return;

View File

@@ -1,4 +1,5 @@
import type { EventLogEntry } from "./app-events.ts";
import type { ChatSendOptions } from "./app-chat.ts";
import type { CompactionStatus, FallbackStatus } from "./app-tool-stream.ts";
import type { ChatInputHistoryKeyInput, ChatInputHistoryKeyResult } from "./chat/input-history.ts";
import type { RealtimeTalkStatus } from "./chat/realtime-talk.ts";
@@ -460,7 +461,7 @@ export type AppViewState = {
handleChatDraftChange: (next: string) => void;
handleChatInputHistoryKey: (input: ChatInputHistoryKeyInput) => ChatInputHistoryKeyResult;
resetChatInputHistoryNavigation: () => void;
handleSendChat: (messageOverride?: string, opts?: { restoreDraft?: boolean }) => Promise<void>;
handleSendChat: (messageOverride?: string, opts?: ChatSendOptions) => Promise<void>;
toggleRealtimeTalk: () => Promise<void>;
steerQueuedChatMessage: (id: string) => Promise<void>;
handleAbortChat: () => Promise<void>;