From 981110a42e2383244ed023660faaa62b7cea986e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Almeida?= <57680069+zeval@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:35:12 +0100 Subject: [PATCH] feat: localize reset context confirmation (#2880) --- .../task/chat/chat-input-toolbar-desktop.tsx | 2 +- .../task/chat/chat-input-toolbar-mobile.tsx | 224 +++++++++++------- .../task/chat/chat-input-toolbar.test.tsx | 12 +- .../task/chat/reset-context-button.test.tsx | 135 ++++++++++- .../task/chat/reset-context-button.tsx | 182 ++++++++++---- .../mobile-reset-context-confirmation.spec.ts | 88 +++++++ .../reset-context-confirmation-helpers.ts | 70 ++++++ .../chat/reset-context-confirmation.spec.ts | 48 ++++ 8 files changed, 610 insertions(+), 151 deletions(-) create mode 100644 apps/web/e2e/tests/chat/mobile-reset-context-confirmation.spec.ts create mode 100644 apps/web/e2e/tests/chat/reset-context-confirmation-helpers.ts create mode 100644 apps/web/e2e/tests/chat/reset-context-confirmation.spec.ts diff --git a/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx b/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx index 3acddc685e..61271b9e7d 100644 --- a/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar-desktop.tsx @@ -102,7 +102,7 @@ function buildCollapsibleItems(props: DesktopToolbarProps): ToolbarItemConfig[] id: "reset-context", section: "right", visible: !!props.sessionId && !props.isAgentBusy, - render: () => , + render: () => , }, { id: "sessions", diff --git a/apps/web/components/task/chat/chat-input-toolbar-mobile.tsx b/apps/web/components/task/chat/chat-input-toolbar-mobile.tsx index fcc8cacc4e..61eeb8656a 100644 --- a/apps/web/components/task/chat/chat-input-toolbar-mobile.tsx +++ b/apps/web/components/task/chat/chat-input-toolbar-mobile.tsx @@ -1,5 +1,6 @@ "use client"; +import { useState } from "react"; import { IconAt } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { ModelSelector } from "@/components/task/model-selector"; @@ -55,6 +56,11 @@ type MobileToolbarProps = { composerSurface?: "task-chat" | "quick-chat"; }; +type MobileLeftActionsProps = MobileToolbarProps & { + resetConfirmationOpen: boolean; + onResetConfirmationOpenChange: (open: boolean) => void; +}; + function mobileContextButton(contextCount: number) { return ( , + 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(); +});