feat: localize reset context confirmation (#2880)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user