- {!props.hidePlanMode && (
-
- )}
- {!props.hideAgentControls && (
- <>
-
-
-
-
-
-
-
-
-
- {!props.hideSessionsDropdown && (
-
-
-
- )}
- >
- )}
- {props.onAttachFiles &&
}
-
-
-
+
+ {!props.resetConfirmationOpen ?
: null}
{!props.hideAgentControls && props.sessionId && !props.isAgentBusy && (
-
-
+
+
)}
- {!props.hideAgentControls && !props.isAgentBusy && (
+ {!props.resetConfirmationOpen && !props.hideAgentControls && !props.isAgentBusy && (
{})}
@@ -146,55 +178,67 @@ function MobileLeftActions(props: MobileToolbarProps) {
)}
-
+ {!props.resetConfirmationOpen ? (
+
+ ) : null}
);
}
export function MobileChatInputToolbar(props: MobileToolbarProps) {
+ const [resetConfirmationOpen, setResetConfirmationOpen] = useState(false);
+
return (
-
-
-
- {props.planModeEnabled && !props.isAgentBusy && props.onImplementPlan && (
-
- )}
- {!props.hideAgentControls && (
-
+ {!resetConfirmationOpen ? (
+
+
+ {props.planModeEnabled && !props.isAgentBusy && props.onImplementPlan && (
+
+ )}
+ {!props.hideAgentControls && (
+
+ )}
+
- )}
-
-
+
+ ) : null}
);
}
diff --git a/apps/web/components/task/chat/chat-input-toolbar.test.tsx b/apps/web/components/task/chat/chat-input-toolbar.test.tsx
index 3a7b0f0dfa..a8353327c7 100644
--- a/apps/web/components/task/chat/chat-input-toolbar.test.tsx
+++ b/apps/web/components/task/chat/chat-input-toolbar.test.tsx
@@ -93,7 +93,11 @@ vi.mock("./implement-plan-button", () => ({
}));
vi.mock("./reset-context-button", () => ({
- ResetContextButton: () =>
,
+ ResetContextButton: ({ presentation = "desktop" }: { presentation?: "desktop" | "mobile" }) => (
+
+ ),
}));
vi.mock("./chat-input-plugin-actions", () => ({
@@ -388,6 +392,9 @@ describe("ChatInputToolbar responsive wrapper", () => {
expect(screen.queryByTestId("mock-sessions-dropdown")).toBeNull();
expect(screen.getByTestId("toolbar-item-context")).toBeTruthy();
expect(screen.getByTestId("toolbar-item-reset-context")).toBeTruthy();
+ expect(screen.getByTestId("reset-context-button").getAttribute("data-reset-presentation")).toBe(
+ "mobile",
+ );
expect(screen.getByTestId("toolbar-item-enhance")).toBeTruthy();
expect(screen.getByTestId("mock-mode-selector").className).toContain("max-w-[46vw]");
expect(screen.getByTestId("mock-model-selector").className).toContain("max-w-[56vw]");
@@ -420,6 +427,9 @@ describe("ChatInputToolbar responsive wrapper", () => {
renderFullToolbar();
expect(screen.getByTestId("chat-input-toolbar")).toBeTruthy();
+ expect(
+ screen.getByTestId("reset-context-button").getAttribute("data-reset-presentation"),
+ ).toBe("desktop");
expect(screen.queryByTestId(MOBILE_TOOLBAR_TEST_ID)).toBeNull();
},
);
diff --git a/apps/web/components/task/chat/reset-context-button.test.tsx b/apps/web/components/task/chat/reset-context-button.test.tsx
index be0c2eade8..5b5e156947 100644
--- a/apps/web/components/task/chat/reset-context-button.test.tsx
+++ b/apps/web/components/task/chat/reset-context-button.test.tsx
@@ -19,10 +19,22 @@ vi.mock("@/components/state-provider", () => ({
import { ResetContextButton } from "./reset-context-button";
-function renderResetButton() {
+const RESET_CONTEXT_BUTTON_TEST_ID = "reset-context-button";
+const RESET_CONTEXT_CONFIRM_TEST_ID = "reset-context-confirm";
+const RESET_CONTEXT_WARNING =
+ "This will clear the agent's conversation history and start a fresh context. Your workspace, files, and git state will be preserved.";
+
+function renderResetButton(
+ presentation: "desktop" | "mobile" = "desktop",
+ onConfirmationOpenChange?: (open: boolean) => void,
+) {
return render(
-
+
,
);
}
@@ -37,11 +49,11 @@ describe("ResetContextButton context-window invalidation", () => {
mocks.request.mockResolvedValue({ success: true });
});
- it("clears the session usage cache after a successful reset", async () => {
+ it("confirms from an anchored popover and clears the session usage cache once", async () => {
renderResetButton();
- fireEvent.click(screen.getByTestId("reset-context-button"));
- fireEvent.click(await screen.findByTestId("reset-context-confirm"));
+ fireEvent.click(screen.getByTestId(RESET_CONTEXT_BUTTON_TEST_ID));
+ fireEvent.click(await screen.findByTestId(RESET_CONTEXT_CONFIRM_TEST_ID));
await waitFor(() => {
expect(mocks.request).toHaveBeenCalledWith(
@@ -51,6 +63,19 @@ describe("ResetContextButton context-window invalidation", () => {
);
expect(mocks.clearContextWindow).toHaveBeenCalledWith("session-1");
});
+ expect(mocks.request).toHaveBeenCalledTimes(1);
+ expect(screen.queryByRole("alertdialog")).toBeNull();
+ });
+
+ it("leaves transcript and context untouched when desktop confirmation is cancelled", async () => {
+ renderResetButton();
+
+ fireEvent.click(screen.getByTestId(RESET_CONTEXT_BUTTON_TEST_ID));
+ fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
+
+ expect(mocks.request).not.toHaveBeenCalled();
+ expect(mocks.clearContextWindow).not.toHaveBeenCalled();
+ expect(screen.queryByTestId("reset-context-confirm-popover")).toBeNull();
});
it("keeps the session usage cache when reset fails", async () => {
@@ -59,13 +84,109 @@ describe("ResetContextButton context-window invalidation", () => {
renderResetButton();
- fireEvent.click(screen.getByTestId("reset-context-button"));
+ fireEvent.click(screen.getByTestId(RESET_CONTEXT_BUTTON_TEST_ID));
await act(async () => {
- fireEvent.click(await screen.findByTestId("reset-context-confirm"));
+ fireEvent.click(await screen.findByTestId(RESET_CONTEXT_CONFIRM_TEST_ID));
});
await waitFor(() => expect(mocks.request).toHaveBeenCalled());
expect(mocks.clearContextWindow).not.toHaveBeenCalled();
consoleError.mockRestore();
});
+
+ it("keeps reset busy while the confirmed request is pending", async () => {
+ let resolveRequest!: (value: { success: boolean }) => void;
+ mocks.request.mockReturnValue(
+ new Promise<{ success: boolean }>((resolve) => {
+ resolveRequest = resolve;
+ }),
+ );
+
+ renderResetButton();
+ fireEvent.click(screen.getByTestId(RESET_CONTEXT_BUTTON_TEST_ID));
+ fireEvent.click(await screen.findByTestId(RESET_CONTEXT_CONFIRM_TEST_ID));
+
+ await waitFor(() => expect(mocks.request).toHaveBeenCalledTimes(1));
+ expect(screen.getByTestId(RESET_CONTEXT_BUTTON_TEST_ID)).toHaveProperty("disabled", true);
+ expect(mocks.clearContextWindow).not.toHaveBeenCalled();
+
+ await act(async () => {
+ resolveRequest({ success: true });
+ });
+ });
+});
+
+describe("ResetContextButton mobile presentation", () => {
+ beforeEach(() => {
+ mocks.request.mockResolvedValue({ success: true });
+ });
+
+ it("uses inline touch actions without opening a second overlay", async () => {
+ renderResetButton("mobile");
+
+ fireEvent.click(screen.getByTestId(RESET_CONTEXT_BUTTON_TEST_ID));
+
+ const inlineConfirmation = await screen.findByTestId("reset-context-inline-confirm");
+ expect(inlineConfirmation).toBeTruthy();
+ expect(screen.queryByTestId("reset-context-confirm-popover")).toBeNull();
+ expect(screen.getByTestId(RESET_CONTEXT_CONFIRM_TEST_ID).className).toContain("h-11");
+
+ fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(mocks.request).not.toHaveBeenCalled();
+ expect(mocks.clearContextWindow).not.toHaveBeenCalled();
+ });
+
+ it("shows the destructive warning in the inline confirmation", async () => {
+ renderResetButton("mobile");
+
+ fireEvent.click(screen.getByTestId(RESET_CONTEXT_BUTTON_TEST_ID));
+
+ expect(await screen.findByText(RESET_CONTEXT_WARNING)).toBeTruthy();
+ });
+
+ it("returns focus to the reset trigger after cancelling inline", async () => {
+ renderResetButton("mobile");
+
+ fireEvent.click(screen.getByTestId(RESET_CONTEXT_BUTTON_TEST_ID));
+ fireEvent.click(await screen.findByRole("button", { name: "Cancel" }));
+
+ await waitFor(() =>
+ expect(document.activeElement).toBe(screen.getByTestId(RESET_CONTEXT_BUTTON_TEST_ID)),
+ );
+ });
+
+ it("returns focus to the reset trigger after dismissing inline with Escape", async () => {
+ renderResetButton("mobile");
+
+ fireEvent.click(screen.getByTestId(RESET_CONTEXT_BUTTON_TEST_ID));
+ fireEvent.keyDown(await screen.findByRole("group"), { key: "Escape" });
+
+ await waitFor(() =>
+ expect(document.activeElement).toBe(screen.getByTestId(RESET_CONTEXT_BUTTON_TEST_ID)),
+ );
+ });
+
+ it("dispatches reset once after inline confirmation", async () => {
+ renderResetButton("mobile");
+
+ fireEvent.click(screen.getByTestId(RESET_CONTEXT_BUTTON_TEST_ID));
+ fireEvent.click(await screen.findByTestId(RESET_CONTEXT_CONFIRM_TEST_ID));
+
+ await waitFor(() => {
+ expect(mocks.request).toHaveBeenCalledTimes(1);
+ expect(mocks.clearContextWindow).toHaveBeenCalledWith("session-1");
+ });
+ expect(document.activeElement).not.toBe(screen.getByTestId(RESET_CONTEXT_BUTTON_TEST_ID));
+ });
+
+ it("reports one close transition before dispatching the confirmed reset", async () => {
+ const onConfirmationOpenChange = vi.fn();
+ renderResetButton("mobile", onConfirmationOpenChange);
+
+ fireEvent.click(screen.getByTestId(RESET_CONTEXT_BUTTON_TEST_ID));
+ fireEvent.click(await screen.findByTestId(RESET_CONTEXT_CONFIRM_TEST_ID));
+
+ await waitFor(() => expect(mocks.request).toHaveBeenCalledTimes(1));
+ expect(onConfirmationOpenChange.mock.calls.map(([open]) => open)).toEqual([true, false]);
+ });
});
diff --git a/apps/web/components/task/chat/reset-context-button.tsx b/apps/web/components/task/chat/reset-context-button.tsx
index 451bcd88ea..764e19ee39 100644
--- a/apps/web/components/task/chat/reset-context-button.tsx
+++ b/apps/web/components/task/chat/reset-context-button.tsx
@@ -1,29 +1,98 @@
"use client";
-import { useState, useCallback } from "react";
+import { useCallback, useEffect, useLayoutEffect, useRef, useState, type RefObject } from "react";
import { IconRotateClockwise2 } from "@tabler/icons-react";
import { GridSpinner } from "@/components/grid-spinner";
import { Button } from "@kandev/ui/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
-import {
- AlertDialog,
- AlertDialogAction,
- AlertDialogCancel,
- AlertDialogContent,
- AlertDialogDescription,
- AlertDialogFooter,
- AlertDialogHeader,
- AlertDialogTitle,
-} from "@kandev/ui/alert-dialog";
import { useAppStore } from "@/components/state-provider";
+import { ActionConfirmPopover } from "@/components/confirmation/action-confirm-popover";
+import { InlineConfirmActions } from "@/components/confirmation/inline-confirm-actions";
import { getWebSocketClient } from "@/lib/ws/connection";
import { useTranslation } from "react-i18next";
-export function ResetContextButton({ sessionId }: { sessionId: string }) {
+type ResetContextButtonProps = {
+ sessionId: string;
+ presentation?: "desktop" | "mobile";
+ onConfirmationOpenChange?: (open: boolean) => void;
+};
+
+type ResetContextTriggerProps = {
+ actionRef: RefObject
;
+ isResetting: boolean;
+ ariaLabel: string;
+ tooltip: string;
+ onOpen: () => void;
+};
+
+function ResetContextTrigger({
+ actionRef,
+ isResetting,
+ ariaLabel,
+ tooltip,
+ onOpen,
+}: ResetContextTriggerProps) {
+ return (
+
+
+
+
+ {tooltip}
+
+ );
+}
+
+export function ResetContextButton({
+ sessionId,
+ presentation = "desktop",
+ onConfirmationOpenChange,
+}: ResetContextButtonProps) {
const { t } = useTranslation();
const [confirmOpen, setConfirmOpen] = useState(false);
const [isResetting, setIsResetting] = useState(false);
+ const actionRef = useRef(null);
+ const restoreFocusAfterCancelRef = useRef(false);
const clearContextWindow = useAppStore((state) => state.clearContextWindow);
+ const resetContextLabel = t("task:resetContext");
+ const resetContextAriaLabel = t("task:resetAgentContext");
+ const resetContextTooltip = t("task:resetAgentContextClearsConversationHistory");
+
+ const updateConfirmationOpen = useCallback(
+ (open: boolean) => {
+ setConfirmOpen(open);
+ onConfirmationOpenChange?.(open);
+ },
+ [onConfirmationOpenChange],
+ );
+
+ useEffect(
+ () => () => {
+ onConfirmationOpenChange?.(false);
+ },
+ [onConfirmationOpenChange],
+ );
+
+ useLayoutEffect(() => {
+ if (confirmOpen || !restoreFocusAfterCancelRef.current) return;
+ restoreFocusAfterCancelRef.current = false;
+ actionRef.current?.focus();
+ }, [confirmOpen]);
const handleReset = useCallback(async () => {
setIsResetting(true);
@@ -36,51 +105,60 @@ export function ResetContextButton({ sessionId }: { sessionId: string }) {
console.error("Failed to reset agent context:", error);
} finally {
setIsResetting(false);
- setConfirmOpen(false);
}
}, [clearContextWindow, sessionId]);
+ const handleMobileCancel = () => {
+ restoreFocusAfterCancelRef.current = true;
+ updateConfirmationOpen(false);
+ };
+
+ const resetContextTrigger = (
+ updateConfirmationOpen(true)}
+ />
+ );
+
+ if (presentation === "mobile") {
+ return confirmOpen ? (
+ updateConfirmationOpen(false)}
+ onConfirm={handleReset}
+ />
+ ) : (
+ resetContextTrigger
+ );
+ }
+
return (
<>
-
-
-
-
- {t("task:resetAgentContextClearsConversationHistory")}
-
-
-
-
- {t("task:resetAgentContext")}
- {t("task:thisWillClearTheAgentS")}
-
-
- {t("common:cancel")}
-
- {isResetting ? t("task:resetting") : t("task:resetContext")}
-
-
-
-
+ {resetContextTrigger}
+
>
);
}
diff --git a/apps/web/e2e/tests/chat/mobile-reset-context-confirmation.spec.ts b/apps/web/e2e/tests/chat/mobile-reset-context-confirmation.spec.ts
new file mode 100644
index 0000000000..76dc4528d0
--- /dev/null
+++ b/apps/web/e2e/tests/chat/mobile-reset-context-confirmation.spec.ts
@@ -0,0 +1,88 @@
+import { test, expect } from "../../fixtures/test-base";
+import { watchWs } from "../../helpers/causal-waits";
+import {
+ seedResetContextSession,
+ seedStaleContextWindow,
+} from "./reset-context-confirmation-helpers";
+
+test("mobile reset context confirms inline without stacking another overlay", async ({
+ testPage,
+ apiClient,
+ seedData,
+ prCapture,
+}) => {
+ const ws = watchWs(testPage);
+ const session = await seedResetContextSession(
+ testPage,
+ apiClient,
+ seedData,
+ "Mobile Reset Context Confirmation",
+ );
+ await seedStaleContextWindow(testPage);
+
+ const contextRing = testPage.getByRole("button", { name: "Context window: 95% used" });
+ await expect(contextRing).toBeVisible();
+
+ await session.resetContextButton().tap();
+ const inlineConfirmation = testPage.getByTestId("reset-context-inline-confirm");
+ const warning = inlineConfirmation.getByText(
+ "This will clear the agent's conversation history and start a fresh context. Your workspace, files, and git state will be preserved.",
+ { exact: true },
+ );
+ await expect(inlineConfirmation).toBeVisible();
+ await expect(warning).toBeVisible();
+ await expect(testPage.getByRole("alertdialog")).toHaveCount(0);
+ await prCapture.screenshot("mobile-reset-context-confirmation", {
+ caption: "Mobile toolbar reset context confirmation",
+ });
+
+ const confirmBox = await inlineConfirmation.getByTestId("reset-context-confirm").boundingBox();
+ expect(confirmBox).not.toBeNull();
+ expect(confirmBox!.height).toBeGreaterThanOrEqual(44);
+ const [warningBox, viewportWidth] = await Promise.all([
+ warning.boundingBox(),
+ testPage.evaluate(() => window.innerWidth),
+ ]);
+ expect(warningBox).not.toBeNull();
+ expect(warningBox!.x).toBeGreaterThanOrEqual(0);
+ expect(warningBox!.x + warningBox!.width).toBeLessThanOrEqual(viewportWidth);
+ const warningIsTopmost = await warning.evaluate((element) => {
+ const rect = element.getBoundingClientRect();
+ return [0.25, 0.5, 0.75].every((ratio) => {
+ const hit = document.elementFromPoint(rect.left + rect.width * ratio, rect.top + 8);
+ return hit === element || element.contains(hit);
+ });
+ });
+ expect(warningIsTopmost).toBe(true);
+ const confirmationBackground = await testPage
+ .getByTestId("mobile-chat-input-toolbar")
+ .evaluate((element) => getComputedStyle(element).backgroundColor);
+ expect(confirmationBackground).not.toBe("rgba(0, 0, 0, 0)");
+
+ await inlineConfirmation.getByRole("button", { name: "Cancel" }).tap();
+ await expect(inlineConfirmation).toHaveCount(0);
+ await expect(session.resetContextButton()).toBeFocused();
+ await expect(contextRing).toBeVisible();
+ await expect(session.contextResetDivider()).toHaveCount(0);
+
+ await session.resetContextButton().tap();
+ await expect(inlineConfirmation).toBeVisible();
+ await testPage.keyboard.press("Escape");
+ await expect(inlineConfirmation).toHaveCount(0);
+ await expect(session.resetContextButton()).toBeFocused();
+
+ await session.resetContextButton().tap();
+ await expect(inlineConfirmation).toBeVisible();
+ const resetResponse = ws.waitForResponse("session.reset_context");
+ await inlineConfirmation.getByTestId("reset-context-confirm").tap();
+ await resetResponse;
+
+ await expect(session.contextResetDivider()).toBeVisible();
+ await expect(session.resetContextButton()).toBeEnabled();
+
+ const hasHorizontalOverflow = await testPage.evaluate(() => {
+ const root = document.scrollingElement ?? document.documentElement;
+ return root.scrollWidth > root.clientWidth + 1;
+ });
+ expect(hasHorizontalOverflow).toBe(false);
+});
diff --git a/apps/web/e2e/tests/chat/reset-context-confirmation-helpers.ts b/apps/web/e2e/tests/chat/reset-context-confirmation-helpers.ts
new file mode 100644
index 0000000000..bf927bcf06
--- /dev/null
+++ b/apps/web/e2e/tests/chat/reset-context-confirmation-helpers.ts
@@ -0,0 +1,70 @@
+import type { Page } from "@playwright/test";
+
+import type { SeedData } from "../../fixtures/test-base";
+import type { ApiClient } from "../../helpers/api-client";
+import { SessionPage } from "../../pages/session-page";
+
+type ContextWindowStoreWindow = Window & {
+ __KANDEV_E2E_STORE__?: {
+ getState: () => {
+ tasks: { activeSessionId: string | null };
+ setContextWindow: (
+ sessionId: string,
+ contextWindow: {
+ size: number;
+ used: number;
+ remaining: number;
+ efficiency: number;
+ compactionCount: number;
+ source: "acp" | "api";
+ },
+ ) => void;
+ };
+ };
+};
+
+export async function seedResetContextSession(
+ testPage: Page,
+ apiClient: ApiClient,
+ seedData: SeedData,
+ title: string,
+): Promise {
+ const task = await apiClient.createTaskWithAgent(
+ seedData.workspaceId,
+ title,
+ seedData.agentProfileId,
+ {
+ description: "/e2e:simple-message",
+ workflow_id: seedData.workflowId,
+ workflow_step_id: seedData.startStepId,
+ repository_ids: [seedData.repositoryId],
+ },
+ );
+
+ if (!task.session_id) throw new Error("createTaskWithAgent did not return a session_id");
+
+ await testPage.goto(`/t/${task.id}`);
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+ await session.waitForChatIdle({ timeout: 30_000 });
+ return session;
+}
+
+export async function seedStaleContextWindow(testPage: Page): Promise {
+ await testPage.evaluate(() => {
+ const store = (window as ContextWindowStoreWindow).__KANDEV_E2E_STORE__;
+ if (!store) throw new Error("E2E store bridge is unavailable");
+
+ const sessionId = store.getState().tasks.activeSessionId;
+ if (!sessionId) throw new Error("No active session is available");
+
+ store.getState().setContextWindow(sessionId, {
+ size: 200_000,
+ used: 190_000,
+ remaining: 10_000,
+ efficiency: 95,
+ compactionCount: 0,
+ source: "acp",
+ });
+ });
+}
diff --git a/apps/web/e2e/tests/chat/reset-context-confirmation.spec.ts b/apps/web/e2e/tests/chat/reset-context-confirmation.spec.ts
new file mode 100644
index 0000000000..b389f30ee8
--- /dev/null
+++ b/apps/web/e2e/tests/chat/reset-context-confirmation.spec.ts
@@ -0,0 +1,48 @@
+import { test, expect } from "../../fixtures/test-base";
+import { watchWs } from "../../helpers/causal-waits";
+import {
+ seedResetContextSession,
+ seedStaleContextWindow,
+} from "./reset-context-confirmation-helpers";
+
+test("desktop reset context confirms at its toolbar action and preserves cancellation", async ({
+ testPage,
+ apiClient,
+ seedData,
+ prCapture,
+}) => {
+ const ws = watchWs(testPage);
+ const session = await seedResetContextSession(
+ testPage,
+ apiClient,
+ seedData,
+ "Desktop Reset Context Confirmation",
+ );
+ await seedStaleContextWindow(testPage);
+
+ const contextRing = testPage.getByRole("button", { name: "Context window: 95% used" });
+ await expect(contextRing).toBeVisible();
+
+ await session.resetContextButton().click();
+ const confirmation = testPage.getByTestId("reset-context-confirm-popover");
+ await expect(confirmation).toBeVisible();
+ await expect(confirmation).toHaveAttribute("data-slot", "popover-content");
+ await expect(testPage.getByRole("alertdialog")).toHaveCount(0);
+ await prCapture.screenshot("desktop-reset-context-confirmation", {
+ caption: "Desktop toolbar reset context confirmation",
+ });
+
+ await confirmation.getByRole("button", { name: "Cancel" }).click();
+ await expect(confirmation).toHaveCount(0);
+ await expect(contextRing).toBeVisible();
+ await expect(session.contextResetDivider()).toHaveCount(0);
+
+ await session.resetContextButton().click();
+ await expect(confirmation).toBeVisible();
+ const resetResponse = ws.waitForResponse("session.reset_context");
+ await confirmation.getByTestId("reset-context-confirm").click();
+ await resetResponse;
+
+ await expect(session.contextResetDivider()).toBeVisible();
+ await expect(session.resetContextButton()).toBeEnabled();
+});