diff --git a/CHANGELOG.md b/CHANGELOG.md index 2378f078826..db0e1899415 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/ui/src/ui/app-chat.test.ts b/ui/src/ui/app-chat.test.ts index abb19e459e3..5c99c318dcb 100644 --- a/ui/src/ui/app-chat.test.ts +++ b/ui/src/ui/app-chat.test.ts @@ -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", diff --git a/ui/src/ui/app-chat.ts b/ui/src/ui/app-chat.ts index 1e8332b4cdc..9a539db264d 100644 --- a/ui/src/ui/app-chat.ts +++ b/ui/src/ui/app-chat.ts @@ -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); diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 5fb83e7a74b..d02b0973388 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -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; diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index fee8e01b4ee..694907f6b18 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -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; + handleSendChat: (messageOverride?: string, opts?: ChatSendOptions) => Promise; toggleRealtimeTalk: () => Promise; steerQueuedChatMessage: (id: string) => Promise; handleAbortChat: () => Promise;