feat: localize reset context confirmation (#2880)

This commit is contained in:
José Almeida
2026-08-21 16:35:12 +01:00
committed by GitHub
parent f2f874cdbc
commit 981110a42e
8 changed files with 610 additions and 151 deletions
@@ -102,7 +102,7 @@ function buildCollapsibleItems(props: DesktopToolbarProps): ToolbarItemConfig[]
id: "reset-context",
section: "right",
visible: !!props.sessionId && !props.isAgentBusy,
render: () => <ResetContextButton sessionId={props.sessionId!} />,
render: () => <ResetContextButton sessionId={props.sessionId!} presentation="desktop" />,
},
{
id: "sessions",
@@ -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 (
<Button
@@ -75,67 +81,93 @@ function mobileContextButton(contextCount: number) {
);
}
function MobileLeftActions(props: MobileToolbarProps) {
function MobileDefaultLeftActions(props: MobileToolbarProps) {
return (
<>
{!props.hidePlanMode && (
<PlanToggleButton
planModeEnabled={props.planModeEnabled}
planModeAvailable={props.planModeAvailable}
onPlanModeChange={props.onPlanModeChange}
/>
)}
{!props.hideAgentControls && (
<>
<div data-testid="toolbar-item-mcp">
<McpIndicator
mcpServers={props.mcpServers}
attachmentHistory={props.mcpAttachmentHistory}
/>
</div>
<div data-testid="toolbar-item-mode">
<ModeSelector sessionId={props.sessionId} triggerClassName="max-w-[46vw]" />
</div>
<div data-testid="toolbar-item-model">
<ModelSelector
sessionId={props.sessionId}
triggerClassName="max-w-[56vw] min-w-0 overflow-hidden"
/>
</div>
{!props.hideSessionsDropdown && (
<div data-testid="toolbar-item-sessions">
<SessionsDropdown
taskId={props.taskId}
activeSessionId={props.sessionId}
taskTitle={props.taskTitle}
/>
</div>
)}
</>
)}
{props.onAttachFiles && <AttachFilesButton onClick={props.onAttachFiles} />}
<div data-testid="toolbar-item-context">
<ContextPopover
open={props.contextPopoverOpen}
onOpenChange={props.onContextPopoverOpenChange}
trigger={mobileContextButton(props.contextCount)}
sessionId={props.sessionId}
planContextEnabled={props.planContextEnabled}
contextFiles={props.contextFiles}
onToggleFile={props.onToggleFile}
/>
</div>
</>
);
}
function MobileLeftActions(props: MobileLeftActionsProps) {
return (
<div className="relative min-w-0 flex-1">
<div
data-testid="mobile-chat-toolbar-left-actions"
className="min-w-0 overflow-x-auto overscroll-x-contain scrollbar-hide pr-8"
className={
props.resetConfirmationOpen
? "min-w-0"
: "min-w-0 overflow-x-auto overscroll-x-contain scrollbar-hide pr-8"
}
>
<div className="flex w-max items-center gap-0.5 pr-3">
{!props.hidePlanMode && (
<PlanToggleButton
planModeEnabled={props.planModeEnabled}
planModeAvailable={props.planModeAvailable}
onPlanModeChange={props.onPlanModeChange}
/>
)}
{!props.hideAgentControls && (
<>
<div data-testid="toolbar-item-mcp">
<McpIndicator
mcpServers={props.mcpServers}
attachmentHistory={props.mcpAttachmentHistory}
/>
</div>
<div data-testid="toolbar-item-mode">
<ModeSelector sessionId={props.sessionId} triggerClassName="max-w-[46vw]" />
</div>
<div data-testid="toolbar-item-model">
<ModelSelector
sessionId={props.sessionId}
triggerClassName="max-w-[56vw] min-w-0 overflow-hidden"
/>
</div>
{!props.hideSessionsDropdown && (
<div data-testid="toolbar-item-sessions">
<SessionsDropdown
taskId={props.taskId}
activeSessionId={props.sessionId}
taskTitle={props.taskTitle}
/>
</div>
)}
</>
)}
{props.onAttachFiles && <AttachFilesButton onClick={props.onAttachFiles} />}
<div data-testid="toolbar-item-context">
<ContextPopover
open={props.contextPopoverOpen}
onOpenChange={props.onContextPopoverOpenChange}
trigger={mobileContextButton(props.contextCount)}
sessionId={props.sessionId}
planContextEnabled={props.planContextEnabled}
contextFiles={props.contextFiles}
onToggleFile={props.onToggleFile}
/>
</div>
<div
className={
props.resetConfirmationOpen
? "flex w-full min-w-0 items-center"
: "flex w-max items-center gap-0.5 pr-3"
}
>
{!props.resetConfirmationOpen ? <MobileDefaultLeftActions {...props} /> : null}
{!props.hideAgentControls && props.sessionId && !props.isAgentBusy && (
<div data-testid="toolbar-item-reset-context">
<ResetContextButton sessionId={props.sessionId} />
<div
key="reset-context"
data-testid="toolbar-item-reset-context"
className={props.resetConfirmationOpen ? "w-full min-w-0" : undefined}
>
<ResetContextButton
sessionId={props.sessionId}
presentation="mobile"
onConfirmationOpenChange={props.onResetConfirmationOpenChange}
/>
</div>
)}
{!props.hideAgentControls && !props.isAgentBusy && (
{!props.resetConfirmationOpen && !props.hideAgentControls && !props.isAgentBusy && (
<div data-testid="toolbar-item-enhance">
<EnhancePromptButton
onClick={props.onEnhancePrompt ?? (() => {})}
@@ -146,55 +178,67 @@ function MobileLeftActions(props: MobileToolbarProps) {
)}
</div>
</div>
<div
aria-hidden="true"
data-testid="mobile-chat-toolbar-scroll-fade"
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-gradient-to-l from-background via-background/80 to-transparent"
/>
{!props.resetConfirmationOpen ? (
<div
aria-hidden="true"
data-testid="mobile-chat-toolbar-scroll-fade"
className="pointer-events-none absolute inset-y-0 right-0 w-9 bg-gradient-to-l from-background via-background/80 to-transparent"
/>
) : null}
</div>
);
}
export function MobileChatInputToolbar(props: MobileToolbarProps) {
const [resetConfirmationOpen, setResetConfirmationOpen] = useState(false);
return (
<div
data-testid="mobile-chat-input-toolbar"
data-legacy-testid="chat-input-toolbar"
className="flex items-center gap-1 px-1 pt-0 pb-0.5 border-t border-border"
className={`flex items-center gap-1 px-1 pt-0 pb-0.5 border-t border-border ${
resetConfirmationOpen ? "relative z-10 bg-background" : ""
}`}
>
<MobileLeftActions {...props} />
<div className="flex shrink-0 items-center gap-1">
<TokenUsageDisplay sessionId={props.sessionId} />
{props.planModeEnabled && !props.isAgentBusy && props.onImplementPlan && (
<ImplementPlanButton onClick={props.onImplementPlan} />
)}
{!props.hideAgentControls && (
<ChatInputPluginActions
<MobileLeftActions
{...props}
resetConfirmationOpen={resetConfirmationOpen}
onResetConfirmationOpenChange={setResetConfirmationOpen}
/>
{!resetConfirmationOpen ? (
<div className="flex shrink-0 items-center gap-1">
<TokenUsageDisplay sessionId={props.sessionId} />
{props.planModeEnabled && !props.isAgentBusy && props.onImplementPlan && (
<ImplementPlanButton onClick={props.onImplementPlan} />
)}
{!props.hideAgentControls && (
<ChatInputPluginActions
sessionId={props.sessionId}
taskId={props.taskId}
taskTitle={props.taskTitle}
surface={props.composerSurface ?? (props.taskId ? "task-chat" : "quick-chat")}
presentation="mobile"
disabled={props.isDisabled}
submittable={!props.isDisabled && props.hasContent}
disabledReason={props.submitDisabledReason}
composer={props.composerCapability}
/>
)}
<SubmitButton
isAgentBusy={props.isAgentBusy}
canCancelAgent={props.canCancelAgent}
sessionId={props.sessionId}
taskId={props.taskId}
taskTitle={props.taskTitle}
surface={props.composerSurface ?? (props.taskId ? "task-chat" : "quick-chat")}
presentation="mobile"
disabled={props.isDisabled}
submittable={!props.isDisabled && props.hasContent}
disabledReason={props.submitDisabledReason}
composer={props.composerCapability}
hasContent={props.hasContent}
isDisabled={props.isDisabled}
submitDisabledReason={props.submitDisabledReason}
isSending={props.isSending}
planModeEnabled={props.planModeEnabled}
onCancel={props.onCancel}
onSubmit={props.onSubmit}
submitShortcut={props.submitShortcut}
/>
)}
<SubmitButton
isAgentBusy={props.isAgentBusy}
canCancelAgent={props.canCancelAgent}
sessionId={props.sessionId}
hasContent={props.hasContent}
isDisabled={props.isDisabled}
submitDisabledReason={props.submitDisabledReason}
isSending={props.isSending}
planModeEnabled={props.planModeEnabled}
onCancel={props.onCancel}
onSubmit={props.onSubmit}
submitShortcut={props.submitShortcut}
/>
</div>
</div>
) : null}
</div>
);
}
@@ -93,7 +93,11 @@ vi.mock("./implement-plan-button", () => ({
}));
vi.mock("./reset-context-button", () => ({
ResetContextButton: () => <button type="button">Reset context</button>,
ResetContextButton: ({ presentation = "desktop" }: { presentation?: "desktop" | "mobile" }) => (
<button type="button" data-testid="reset-context-button" data-reset-presentation={presentation}>
Reset context
</button>
),
}));
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();
},
);
@@ -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(
<TooltipProvider>
<ResetContextButton sessionId="session-1" />
<ResetContextButton
sessionId="session-1"
presentation={presentation}
onConfirmationOpenChange={onConfirmationOpenChange}
/>
</TooltipProvider>,
);
}
@@ -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]);
});
});
@@ -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<HTMLButtonElement | null>;
isResetting: boolean;
ariaLabel: string;
tooltip: string;
onOpen: () => void;
};
function ResetContextTrigger({
actionRef,
isResetting,
ariaLabel,
tooltip,
onOpen,
}: ResetContextTriggerProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
ref={actionRef}
type="button"
variant="ghost"
size="icon"
aria-label={ariaLabel}
className="h-7 w-7 cursor-pointer hover:bg-muted/40 text-muted-foreground"
onClick={onOpen}
disabled={isResetting}
data-testid="reset-context-button"
>
{isResetting ? (
<GridSpinner className="h-4 w-4" />
) : (
<IconRotateClockwise2 className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</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<HTMLButtonElement>(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 = (
<ResetContextTrigger
actionRef={actionRef}
isResetting={isResetting}
ariaLabel={resetContextAriaLabel}
tooltip={resetContextTooltip}
onOpen={() => updateConfirmationOpen(true)}
/>
);
if (presentation === "mobile") {
return confirmOpen ? (
<InlineConfirmActions
density="touch"
testId="reset-context-inline-confirm"
ariaLabel={resetContextAriaLabel}
description={t("task:thisWillClearTheAgentS")}
cancelLabel={t("common:cancel")}
confirmLabel={resetContextLabel}
confirmAriaLabel={resetContextLabel}
confirmTestId="reset-context-confirm"
onCancel={handleMobileCancel}
onClose={() => updateConfirmationOpen(false)}
onConfirm={handleReset}
/>
) : (
resetContextTrigger
);
}
return (
<>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 cursor-pointer hover:bg-muted/40 text-muted-foreground"
onClick={() => setConfirmOpen(true)}
disabled={isResetting}
data-testid="reset-context-button"
>
{isResetting ? (
<GridSpinner className="h-4 w-4" />
) : (
<IconRotateClockwise2 className="h-4 w-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>{t("task:resetAgentContextClearsConversationHistory")}</TooltipContent>
</Tooltip>
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("task:resetAgentContext")}</AlertDialogTitle>
<AlertDialogDescription>{t("task:thisWillClearTheAgentS")}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">{t("common:cancel")}</AlertDialogCancel>
<AlertDialogAction
onClick={handleReset}
disabled={isResetting}
className="cursor-pointer"
data-testid="reset-context-confirm"
>
{isResetting ? t("task:resetting") : t("task:resetContext")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{resetContextTrigger}
<ActionConfirmPopover
open={confirmOpen}
anchorRef={actionRef}
title={resetContextAriaLabel}
description={t("task:thisWillClearTheAgentS")}
cancelLabel={t("common:cancel")}
confirmLabel={resetContextLabel}
confirmAriaLabel={resetContextLabel}
confirmTestId="reset-context-confirm"
testId="reset-context-confirm-popover"
onOpenChange={updateConfirmationOpen}
onConfirm={handleReset}
/>
</>
);
}
@@ -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);
});
@@ -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<SessionPage> {
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<void> {
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",
});
});
}
@@ -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();
});