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

@@ -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>;