fix: restore cancel controls during active turns (#3705)

This commit is contained in:
Carlos Florêncio
2026-09-16 09:39:13 +01:00
committed by GitHub
parent 60aef37cbd
commit 993b30cb90
34 changed files with 1791 additions and 53 deletions
@@ -67,6 +67,7 @@ function CommandItemRow({
value={cmd.id}
keywords={getCommandSearchTerms(cmd)}
onSelect={() => onSelect(cmd)}
className="[@media(pointer:coarse)]:min-h-12"
>
{cmd.icon && (
<span
+3 -2
View File
@@ -6,6 +6,7 @@ import { createPersistentMotionVisibility } from "@kandev/ui/persistent-motion-v
type GridSpinnerProps = {
className?: string;
ariaLabel?: string;
};
const gridKeyframes: Keyframe[] = [
@@ -17,7 +18,7 @@ const gridKeyframes: Keyframe[] = [
const useCompositorEffect = typeof window === "undefined" ? React.useEffect : React.useLayoutEffect;
export function GridSpinner({ className }: GridSpinnerProps) {
export function GridSpinner({ className, ariaLabel }: GridSpinnerProps) {
const { t } = useTranslation();
const gridRef = React.useRef<HTMLSpanElement>(null);
@@ -55,7 +56,7 @@ export function GridSpinner({ className }: GridSpinnerProps) {
ref={gridRef}
className={`spinner-grid ${className ?? ""}`}
role="status"
aria-label={t("common:loadingIndicatorLabel")}
aria-label={ariaLabel ?? t("common:loadingIndicatorLabel")}
>
<span className="spinner-grid-cube" />
<span className="spinner-grid-cube" />
@@ -0,0 +1,328 @@
import { cleanup, render, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useEffect } from "react";
import { CommandRegistryProvider, useCommands } from "@/lib/commands/command-registry";
import type { CommandItem } from "@/lib/commands/types";
const mocks = vi.hoisted(() => {
const state = {
quickChat: {
isOpen: true,
activeKind: "conversation" as "conversation" | "terminal" | "setup",
activeSessionId: "quick-session",
},
tasks: { activeTaskId: "task-1", activeSessionId: "task-session" },
kanban: { tasks: [{ id: "task-1", title: "Underlying task" }] },
taskSessions: {
items: {
"quick-session": { cancellation_pending: false },
"quick-session-b": { cancellation_pending: false },
"task-session": { cancellation_pending: false },
},
},
chatInput: { cancellingBySessionId: {} as Record<string, boolean> },
};
return {
state,
underlyingCancel: vi.fn(async () => {}),
setCancelTurnPending: (sessionId: string, pending: boolean) => {
if (pending) state.chatInput.cancellingBySessionId[sessionId] = true;
else delete state.chatInput.cancellingBySessionId[sessionId];
},
};
});
vi.mock("react-i18next", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock("@/hooks/use-git-operations", () => ({
useGitOperations: () => ({}),
}));
vi.mock("@/hooks/use-git-with-feedback", () => ({
gitOperationLabel: (t: (key: string) => string, key: string) => t(key),
useGitWithFeedback: () => vi.fn(),
}));
vi.mock("@/hooks/use-panel-actions", () => ({
usePanelActions: () => ({
addBrowser: vi.fn(),
addTerminal: vi.fn(),
addPlan: vi.fn(),
addChanges: vi.fn(),
}),
}));
vi.mock("@/components/vcs/vcs-dialogs", () => ({
useVcsDialogs: () => ({
openCommitDialog: vi.fn(),
openPRDialog: vi.fn(),
}),
}));
vi.mock("@/hooks/use-task-archive-confirm", () => ({
useTaskArchiveConfirm: () => ({
target: null,
requestArchive: vi.fn(),
closeConfirm: vi.fn(),
confirmArchive: vi.fn(),
isPending: false,
}),
}));
vi.mock("@/components/task/new-session-dialog", () => ({
NewSessionDialog: () => null,
}));
vi.mock("@/components/task/new-subtask-dialog", () => ({
NewSubtaskDialog: () => null,
}));
vi.mock("@/components/task/task-archive-confirmation", () => ({
TaskArchiveConfirmation: () => null,
}));
vi.mock("@/lib/ws/connection", () => ({
getWebSocketClient: () => ({ request: mocks.underlyingCancel }),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: typeof mocks.state) => unknown) => selector(mocks.state),
useAppStoreApi: () => ({
getState: () => ({ ...mocks.state, setCancelTurnPending: mocks.setCancelTurnPending }),
}),
}));
import { SessionCommands } from "@/components/session-commands";
import { QuickChatCancelCommands } from "./quick-chat-cancel-commands";
function CommandsProbe({ onCommands }: { onCommands: (commands: CommandItem[]) => void }) {
const commands = useCommands();
useEffect(() => onCommands(commands), [commands, onCommands]);
return null;
}
function cancelCommand(commands: CommandItem[]) {
return commands.find((command) => command.id === "session-cancel");
}
function cancellationCommands(commands: CommandItem[]) {
return commands.filter((command) => command.id === "session-cancel");
}
async function expectCancellationCount(onCommands: ReturnType<typeof vi.fn>, count: number) {
await waitFor(() =>
expect(cancellationCommands(onCommands.mock.lastCall?.[0] ?? [])).toHaveLength(count),
);
return cancellationCommands(onCommands.mock.lastCall?.[0] ?? []);
}
// eslint-disable-next-line max-lines-per-function -- registry lifecycle cases share one focused harness.
describe("QuickChatCancelCommands", () => {
beforeEach(() => {
mocks.state.quickChat.isOpen = true;
mocks.state.quickChat.activeKind = "conversation";
mocks.state.quickChat.activeSessionId = "quick-session";
mocks.state.taskSessions.items["quick-session"].cancellation_pending = false;
mocks.state.taskSessions.items["quick-session-b"].cancellation_pending = false;
mocks.state.taskSessions.items["task-session"].cancellation_pending = false;
mocks.state.chatInput.cancellingBySessionId = {};
mocks.underlyingCancel.mockClear();
});
afterEach(() => cleanup());
it("registers cancellation only for the active structured conversation", async () => {
const onCancel = vi.fn(async () => {});
const onCommands = vi.fn();
const { rerender } = render(
<CommandRegistryProvider>
<QuickChatCancelCommands sessionId="quick-session" isWorking onCancel={onCancel} />
<CommandsProbe onCommands={onCommands} />
</CommandRegistryProvider>,
);
await waitFor(() => expect(cancelCommand(onCommands.mock.lastCall?.[0] ?? [])).toBeDefined());
await cancelCommand(onCommands.mock.lastCall?.[0] ?? [])?.action?.();
expect(onCancel).toHaveBeenCalledOnce();
mocks.state.quickChat.activeKind = "terminal";
rerender(
<CommandRegistryProvider>
<QuickChatCancelCommands sessionId="quick-session" isWorking onCancel={onCancel} />
<CommandsProbe onCommands={onCommands} />
</CommandRegistryProvider>,
);
await waitFor(() => expect(cancelCommand(onCommands.mock.lastCall?.[0] ?? [])).toBeUndefined());
});
it("ignores a second cancellation while the first request is pending", async () => {
let resolveCancel: (() => void) | undefined;
const onCancel = vi.fn(
() =>
new Promise<void>((resolve) => {
resolveCancel = resolve;
}),
);
const onCommands = vi.fn();
render(
<CommandRegistryProvider>
<QuickChatCancelCommands sessionId="quick-session" isWorking onCancel={onCancel} />
<CommandsProbe onCommands={onCommands} />
</CommandRegistryProvider>,
);
await waitFor(() => expect(cancelCommand(onCommands.mock.lastCall?.[0] ?? [])).toBeDefined());
const command = cancelCommand(onCommands.mock.lastCall?.[0] ?? []);
if (!command?.action) throw new Error("Missing Quick Chat cancellation command");
const firstRequest = command.action();
await waitFor(() => expect(onCancel).toHaveBeenCalledOnce());
await command.action();
expect(onCancel).toHaveBeenCalledOnce();
resolveCancel?.();
await firstRequest;
});
it("targets only the active Quick Chat session over a running task while switching and closing", async () => {
const quickA = vi.fn(async () => {});
const quickB = vi.fn(async () => {});
const onCommands = vi.fn();
const { rerender } = render(
<CommandRegistryProvider>
<SessionCommands sessionId="task-session" isAgentRunning />
<QuickChatCancelCommands sessionId="quick-session" isWorking onCancel={quickA} />
<CommandsProbe onCommands={onCommands} />
</CommandRegistryProvider>,
);
let commands = await expectCancellationCount(onCommands, 1);
await commands[0].action?.();
expect(quickA).toHaveBeenCalledOnce();
expect(mocks.underlyingCancel).not.toHaveBeenCalled();
mocks.state.quickChat.activeSessionId = "quick-session-b";
rerender(
<CommandRegistryProvider>
<SessionCommands sessionId="task-session" isAgentRunning />
<QuickChatCancelCommands sessionId="quick-session-b" isWorking onCancel={quickB} />
<CommandsProbe onCommands={onCommands} />
</CommandRegistryProvider>,
);
commands = await expectCancellationCount(onCommands, 1);
await commands[0].action?.();
expect(quickB).toHaveBeenCalledOnce();
expect(quickA).toHaveBeenCalledOnce();
expect(mocks.underlyingCancel).not.toHaveBeenCalled();
mocks.state.quickChat.isOpen = false;
rerender(
<CommandRegistryProvider>
<SessionCommands sessionId="task-session" isAgentRunning />
<CommandsProbe onCommands={onCommands} />
</CommandRegistryProvider>,
);
commands = await expectCancellationCount(onCommands, 1);
await commands[0].action?.();
expect(mocks.underlyingCancel).toHaveBeenCalledWith(
"agent.cancel",
{ session_id: "task-session" },
15000,
);
mocks.state.quickChat.isOpen = true;
rerender(
<CommandRegistryProvider>
<SessionCommands sessionId="task-session" isAgentRunning />
<QuickChatCancelCommands sessionId="quick-session-b" isWorking onCancel={quickB} />
<CommandsProbe onCommands={onCommands} />
</CommandRegistryProvider>,
);
commands = await expectCancellationCount(onCommands, 1);
await commands[0].action?.();
expect(quickB).toHaveBeenCalledTimes(2);
expect(mocks.underlyingCancel).toHaveBeenCalledOnce();
});
it.each([
["setup", "conversation", false, null],
["terminal", "terminal", true, null],
["idle", "conversation", false, null],
[
"disconnected clarification",
"conversation",
true,
{ metadata: { agent_disconnected: true } },
],
] as const)(
"registers no cancellation command for %s Quick Chat state",
async (_name, activeKind, isWorking, pendingClarification) => {
const onCommands = vi.fn();
const { rerender } = render(
<CommandRegistryProvider>
<SessionCommands sessionId="task-session" isAgentRunning />
<QuickChatCancelCommands
sessionId="quick-session"
isWorking={isWorking}
pendingClarification={pendingClarification as never}
onCancel={vi.fn(async () => {})}
/>
<CommandsProbe onCommands={onCommands} />
</CommandRegistryProvider>,
);
mocks.state.quickChat.activeKind = activeKind;
rerender(
<CommandRegistryProvider>
<SessionCommands sessionId="task-session" isAgentRunning />
<QuickChatCancelCommands
sessionId="quick-session"
isWorking={isWorking}
pendingClarification={pendingClarification as never}
onCancel={vi.fn(async () => {})}
/>
<CommandsProbe onCommands={onCommands} />
</CommandRegistryProvider>,
);
await expectCancellationCount(onCommands, 0);
},
);
it("skips backend-pending cancellation and clears its failed request guard after failure", async () => {
const onCancel = vi
.fn<() => Promise<void>>()
.mockRejectedValueOnce(new Error("cancel failed"))
.mockResolvedValueOnce(undefined);
const consoleError = vi.spyOn(console, "error").mockImplementation(() => undefined);
const onCommands = vi.fn();
try {
render(
<CommandRegistryProvider>
<QuickChatCancelCommands sessionId="quick-session" isWorking onCancel={onCancel} />
<CommandsProbe onCommands={onCommands} />
</CommandRegistryProvider>,
);
const commands = await expectCancellationCount(onCommands, 1);
mocks.state.taskSessions.items["quick-session"].cancellation_pending = true;
await commands[0].action?.();
expect(onCancel).not.toHaveBeenCalled();
mocks.state.taskSessions.items["quick-session"].cancellation_pending = false;
await commands[0].action?.();
expect(consoleError).toHaveBeenCalledWith(
"Failed to cancel Quick Chat turn:",
expect.any(Error),
);
await commands[0].action?.();
expect(onCancel).toHaveBeenCalledTimes(2);
} finally {
consoleError.mockRestore();
}
});
});
@@ -0,0 +1,61 @@
"use client";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useRegisterCommands } from "@/hooks/use-register-commands";
import { type Message } from "@/lib/types/http";
import { buildSessionCommands } from "@/components/session-commands";
import { shouldShowCancelAgent } from "@/components/task/chat/types";
type QuickChatCancelCommandsProps = {
sessionId: string;
isWorking: boolean;
pendingClarification?: Message | null;
onCancel: () => Promise<void>;
};
/** Registers only the active structured Quick Chat conversation's cancel command. */
export function QuickChatCancelCommands({
sessionId,
isWorking,
pendingClarification,
onCancel,
}: QuickChatCancelCommandsProps) {
const { t } = useTranslation();
const storeApi = useAppStoreApi();
const isActiveConversation = useAppStore(
(state) =>
state.quickChat.isOpen &&
state.quickChat.activeKind === "conversation" &&
state.quickChat.activeSessionId === sessionId,
);
const canCancel =
isActiveConversation && shouldShowCancelAgent(isWorking, pendingClarification, sessionId);
const cancelTurn = useCallback(async () => {
const state = storeApi.getState();
if (
state.taskSessions.items[sessionId]?.cancellation_pending === true ||
state.chatInput.cancellingBySessionId[sessionId] === true
) {
return;
}
state.setCancelTurnPending(sessionId, true);
try {
await onCancel();
} catch (error) {
console.error("Failed to cancel Quick Chat turn:", error);
} finally {
state.setCancelTurnPending(sessionId, false);
}
}, [onCancel, sessionId, storeApi]);
const commands = useMemo(
() => buildSessionCommands(canCancel, cancelTurn, t),
[canCancel, cancelTurn, t],
);
useRegisterCommands(commands);
return null;
}
@@ -14,6 +14,7 @@ import { ClarificationPanelSection } from "@/components/task/chat/clarification-
import { getSessionWorkspacePath } from "@/lib/session-workspace-path";
import { routePanelMouseDown } from "@/components/task/chat/route-panel-mouse-down";
import { useQuickChatInitialPrompt } from "./use-quick-chat-initial-prompt";
import { QuickChatCancelCommands } from "./quick-chat-cancel-commands";
type QuickChatContentProps = {
sessionId: string;
@@ -95,6 +96,12 @@ export const QuickChatContent = memo(function QuickChatContent({
onMouseDown={handleShortcutScopeMouseDown}
className="flex flex-col flex-1 min-h-0 outline-none"
>
<QuickChatCancelCommands
sessionId={sessionId}
isWorking={panelState.isWorking}
pendingClarification={pendingClarification}
onCancel={handleCancelTurn}
/>
<div className="flex-1 min-h-0 overflow-hidden bg-popover" data-testid="quick-chat-messages">
<MessageList
items={panelState.groupedItems}
@@ -167,4 +167,10 @@ describe("session palette copy", () => {
expect(groups.size).toBe(1);
});
it("omits the task cancel entry while Quick Chat owns the foreground", () => {
const commands = buildSessionCommands(true, vi.fn(), markerT, { suppressCancel: true });
expect(commands.find((command) => command.id === "session-cancel")).toBeUndefined();
});
});
+16 -10
View File
@@ -65,9 +65,10 @@ export function buildSessionCommands(
isAgentRunning: boolean | undefined,
cancelTurn: () => void,
t: TFunction,
options: { suppressCancel?: boolean } = {},
): CommandItem[] {
const items: CommandItem[] = [];
if (isAgentRunning)
if (isAgentRunning && !options.suppressCancel)
items.push({
id: "session-cancel",
label: t("common:commandCancelTurn"),
@@ -362,6 +363,14 @@ function useArchiveCommandConfirmation(archive: ReturnType<typeof useTaskArchive
);
}
function useActiveTaskTitle(): string {
return useAppStore((s) => {
const id = s.tasks.activeTaskId;
if (!id) return "";
return s.kanban.tasks.find((t: { id: string }) => t.id === id)?.title ?? "";
});
}
export function SessionCommands({
sessionId,
baseBranch,
@@ -377,16 +386,11 @@ export function SessionCommands({
const gitWithFeedback = useGitWithFeedback();
const activeTaskId = useAppStore((s) => s.tasks.activeTaskId);
const activeTaskTitle = useAppStore((s) => {
const id = s.tasks.activeTaskId;
if (!id) return "";
return s.kanban.tasks.find((t: { id: string }) => t.id === id)?.title ?? "";
});
const activeTaskTitle = useActiveTaskTitle();
const isQuickChatOpen = useAppStore((s) => s.quickChat.isOpen);
const dialogs = useCommandDialogState();
const { openNewAgent, openSubtask } = dialogs;
const cancelTurn = useCancelTurn(sessionId);
const runGitWithFeedback = useCallback(
async (
operation: () => Promise<{ success: boolean; output: string; error?: string }>,
@@ -401,13 +405,14 @@ export function SessionCommands({
const archive = useTaskArchiveConfirm(activeTaskId);
const { requestArchive } = archive;
const archiveConfirmation = useArchiveCommandConfirmation(archive);
// Session-scoped commands need a live session; task-scoped ones only need the
// task, so they stay available while a session is still being ensured.
const commands = useMemo<CommandItem[]>(() => {
const sessionScoped = sessionId
? [
...buildSessionCommands(isAgentRunning, cancelTurn, t),
...buildSessionCommands(isAgentRunning, cancelTurn, t, {
suppressCancel: isQuickChatOpen,
}),
...(hasWorktree
? buildGitCommands({
git,
@@ -444,6 +449,7 @@ export function SessionCommands({
cancelTurn,
baseBranch,
isAgentRunning,
isQuickChatOpen,
hasWorktree,
isPassthrough,
isTaskArchived,
@@ -255,6 +255,15 @@ it("keeps the composer mounted and focused as the queue fills and drains", () =>
}
});
it.each(["direct", "queue"] as const)(
"projects the derived %s input mode on the composer surface",
(inputMode) => {
renderComposer({ inputMode });
expect(screen.getByTestId("chat-input-area").getAttribute("data-input-mode")).toBe(inputMode);
},
);
describe("resolveInputPlaceholder", () => {
it("invites queueing while a clarification remains pending", () => {
expect(resolveInputPlaceholder(false, undefined, false, true, false)).toBe(
@@ -501,6 +501,7 @@ export function ChatInputArea(props: ChatInputAreaProps) {
return (
<div
data-testid="chat-input-area"
data-input-mode={panelState.inputMode}
className={cn(
"bg-card flex-shrink-0",
!disclosure?.enabled && "px-2 pb-2 pt-1",
@@ -3,7 +3,7 @@ import { cleanup, render, screen } from "@testing-library/react";
import { TooltipProvider } from "@kandev/ui/tooltip";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ChatInputBody, type ChatInputBodyProps } from "./chat-input-body";
import { shouldShowCancelAgent } from "./chat-input-container";
import { shouldShowCancelAgent } from "./types";
const tipTapPropsMock = vi.hoisted(() => vi.fn());
const CHAT_INPUT_GLOW_TEST_ID = "chat-input-glow";
@@ -200,8 +200,20 @@ describe("shouldShowCancelAgent", () => {
attachedClarification,
true,
],
["keeps cancel for a running session without clarification", true, null, true],
])("%s", (_name, isAgentBusy, pendingClarification, expected) => {
expect(shouldShowCancelAgent(isAgentBusy, pendingClarification)).toBe(expected);
["shows cancel for a generating session with direct input", true, null, true],
["shows cancel for background work with direct input", true, null, true],
["shows cancel while the session is starting", true, null, true],
["shows cancel while the executor is preparing", true, null, true],
["hides cancel for an idle session", false, null, false],
])("%s", (_name, isWorking, pendingClarification, expected, sessionId = "session-1") => {
expect(shouldShowCancelAgent(isWorking, pendingClarification, sessionId)).toBe(expected);
});
it("hides cancel when the session identity is missing", () => {
expect(shouldShowCancelAgent(true, null, null)).toBe(false);
});
it("retains the connected clarification override while the session is settled", () => {
expect(shouldShowCancelAgent(false, attachedClarification, "session-1")).toBe(true);
});
});
@@ -2,7 +2,8 @@ import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { ChatInputContainer } from "./chat-input-container";
const { containerState } = vi.hoisted(() => ({
const { bodyProps, containerState } = vi.hoisted(() => ({
bodyProps: { current: null as Record<string, unknown> | null },
containerState: {
showNewSessionDialog: false,
setShowNewSessionDialog: vi.fn(),
@@ -41,7 +42,10 @@ vi.mock("./session-stopped-banner", () => ({
}));
vi.mock("./chat-input-body", () => ({
ChatInputBody: () => <div data-testid="chat-input-body" />,
ChatInputBody: (props: Record<string, unknown>) => {
bodyProps.current = props;
return <div data-testid="chat-input-body" />;
},
}));
vi.mock("@/hooks/domains/session/use-session-recovery-actions", () => ({
@@ -81,12 +85,16 @@ const baseProps = {
planModeEnabled: false,
onPlanModeChange: vi.fn(),
isAgentBusy: false,
isWorking: false,
isStarting: false,
isSending: false,
onCancel: vi.fn(),
};
afterEach(() => cleanup());
afterEach(() => {
cleanup();
bodyProps.current = null;
});
describe("ChatInputContainer launch-error ownership", () => {
it("hides the editor when the task launch card owns a failed session", () => {
@@ -110,3 +118,25 @@ describe("ChatInputContainer launch-error ownership", () => {
expect(screen.queryByTestId("session-stopped-banner")).toBeNull();
});
});
describe("ChatInputContainer cancellation availability", () => {
it("shows cancel for a working direct-input session", () => {
render(<ChatInputContainer {...baseProps} isWorking />);
const editorAreaProps = bodyProps.current?.editorAreaProps as
| { canCancelAgent?: boolean; isAgentBusy?: boolean }
| undefined;
expect(editorAreaProps).toEqual(
expect.objectContaining({ canCancelAgent: true, isAgentBusy: false }),
);
});
it("can hide cancel when the surface callback only dismisses the composer", () => {
render(<ChatInputContainer {...baseProps} isWorking showCancelAgent={false} />);
const editorAreaProps = bodyProps.current?.editorAreaProps as
| { canCancelAgent?: boolean }
| undefined;
expect(editorAreaProps?.canCancelAgent).toBe(false);
});
});
@@ -2,7 +2,7 @@
import { forwardRef, useCallback } from "react";
import type { ContextFile } from "@/lib/state/context-files-store";
import type { ClarificationRequestMetadata, Message } from "@/lib/types/http";
import type { Message } from "@/lib/types/http";
import type { DiffComment } from "@/lib/diff/types";
import type { TaskMentionData } from "@/hooks/use-inline-mention";
import type { MCPAttachmentHistory } from "@/lib/state/slices/session-runtime/types";
@@ -22,7 +22,11 @@ import { useIsUtilityConfigured } from "@/hooks/use-is-utility-configured";
import { usePromptResultDelivery } from "@/hooks/use-prompt-result-delivery";
import { PromptResultRecovery } from "@/components/prompt-result-recovery";
import { t } from "@/lib/i18n";
import { shouldHideChatInputForLaunchError, shouldRenderStoppedSessionBanner } from "./types";
import {
shouldHideChatInputForLaunchError,
shouldRenderStoppedSessionBanner,
shouldShowCancelAgent,
} from "./types";
// Re-export ImageAttachment type for consumers
export type { ImageAttachment } from "./image-attachment-preview";
@@ -74,6 +78,9 @@ type ChatInputContainerProps = {
mcpAttachmentHistory?: MCPAttachmentHistory;
onPlanModeChange: (enabled: boolean) => void;
isAgentBusy: boolean;
isWorking: boolean;
/** False for surfaces whose cancel callback only dismisses the composer. */
showCancelAgent?: boolean;
/** True when a send would be delivered into the running turn (mid-turn
* steering) rather than queued. Defaults to false. */
supportsSteering?: boolean;
@@ -163,15 +170,6 @@ type EnhancePromptExtras = {
isUtilityConfigured?: boolean;
};
export function shouldShowCancelAgent(
isAgentBusy: boolean,
pendingClarification: Message | null | undefined,
): boolean {
if (!pendingClarification) return isAgentBusy;
return !(pendingClarification.metadata as ClarificationRequestMetadata | undefined)
?.agent_disconnected;
}
function buildEditorAreaProps(
s: ContainerState,
p: NormalizedChatInputProps,
@@ -204,7 +202,9 @@ function buildEditorAreaProps(
fileInputRef: s.fileInputRef,
showRequestChangesTooltip: p.showRequestChangesTooltip,
isAgentBusy: p.isAgentBusy || !!(p.pendingClarification && p.onClarificationResolved),
canCancelAgent: shouldShowCancelAgent(p.isAgentBusy, p.pendingClarification),
canCancelAgent:
p.showCancelAgent !== false &&
shouldShowCancelAgent(p.isWorking, p.pendingClarification, p.sessionId),
onPlanModeChange: p.onPlanModeChange,
taskTitle: p.taskTitle,
taskDescription: p.taskDescription,
@@ -129,7 +129,7 @@ function SendSubmitButton({
export function SubmitButton({
isAgentBusy,
canCancelAgent = isAgentBusy,
canCancelAgent = false,
sessionId,
taskId,
taskTitle,
@@ -187,10 +187,11 @@ export function SubmitButton({
)}
onClick={handleCancelClick}
disabled={isCancelling}
aria-label={t("task:cancelAgent")}
data-testid="cancel-agent-button"
>
{isCancelling ? (
<GridSpinner className="text-destructive" />
<GridSpinner className="text-destructive" ariaLabel={t("task:cancelling")} />
) : (
<IconPlayerPauseFilled className="h-3.5 w-3.5" />
)}
@@ -151,6 +151,7 @@ function renderToolbar(onCancel: () => void | Promise<void>) {
taskId="t1"
taskDescription=""
isAgentBusy
canCancelAgent
isDisabled={false}
isSending={false}
onCancel={onCancel}
@@ -242,6 +243,7 @@ describe("ChatInputToolbar backend cancellation state", () => {
taskId="t1"
taskDescription=""
isAgentBusy
canCancelAgent
isDisabled={false}
isSending={false}
onCancel={() => {}}
@@ -268,6 +270,7 @@ describe("ChatInputToolbar backend cancellation state", () => {
taskId="t1"
taskDescription=""
isAgentBusy
canCancelAgent
isDisabled={false}
isSending={false}
onCancel={onCancel}
@@ -289,7 +292,33 @@ describe("ChatInputToolbar backend cancellation state", () => {
// Without this guard, an impatient user clicking it repeatedly while the agent
// tears down a long-running tool (Claude Monitor, etc.) sends N cancel requests
// to the backend, each producing a duplicate "Turn cancelled by user" message.
// eslint-disable-next-line max-lines-per-function -- cancellation cases share one toolbar harness.
describe("ChatInputToolbar cancel button", () => {
it("gives the cancel icon its localized accessible name", () => {
renderToolbar(() => {});
expect(screen.getByRole("button", { name: "Cancel agent" })).toBeTruthy();
});
it("keeps the cancel button name stable while cancellation is pending", async () => {
const { promise, resolve } = deferred<void>();
const onCancel = vi.fn(() => promise);
renderToolbar(onCancel);
const button = screen.getByTestId(CANCEL_AGENT_BUTTON_TEST_ID) as HTMLButtonElement;
fireEvent.click(button);
await act(async () => {});
expect(button.getAttribute("aria-label")).toBe("Cancel agent");
expect(screen.getByRole("status", { name: "Cancelling..." })).toBeTruthy();
await act(async () => {
resolve();
await promise;
});
});
it.each(["desktop", "mobile"] as const)(
"keeps cancellation progress after a %s toolbar remount",
async (breakpoint) => {
@@ -308,6 +337,7 @@ describe("ChatInputToolbar cancel button", () => {
taskId="t1"
taskDescription=""
isAgentBusy
canCancelAgent
isDisabled={false}
isSending={false}
onCancel={onCancel}
@@ -18,6 +18,7 @@ function composerArgs(errorMessage: string) {
mcpAttachmentHistory: [],
handlePlanModeChange: vi.fn(),
isAgentBusy: false,
isWorking: false,
supportsSteering: false,
isStarting: false,
isPreparingEnvironment: false,
+12 -1
View File
@@ -1,6 +1,6 @@
"use client";
import type { Message, TaskPendingAction } from "@/lib/types/http";
import type { ClarificationRequestMetadata, Message, TaskPendingAction } from "@/lib/types/http";
import type { TaskStatusSummaryActiveError } from "@/lib/types/task-status-summary";
import { extractKandevStem } from "./messages/kandev/parse";
@@ -132,6 +132,17 @@ export function hasPendingClarification(
return hasPendingClarificationMessage || pendingAction === "clarification";
}
export function shouldShowCancelAgent(
isWorking: boolean,
pendingClarification: Message | null | undefined,
sessionId: string | null,
): boolean {
if (!sessionId) return false;
if (!pendingClarification) return isWorking;
return !(pendingClarification.metadata as ClarificationRequestMetadata | undefined)
?.agent_disconnected;
}
export type ShellExecPayload = {
command?: string;
work_dir?: string;
@@ -0,0 +1,60 @@
import { renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { useComposerProps } from "./use-composer-props";
function composerArgs() {
return {
panelState: {
resolvedSessionId: "session-1",
taskId: "task-1",
task: null,
taskDescription: "",
planModeEnabled: false,
planModeAvailable: true,
mcpServers: [],
mcpAttachmentHistory: [],
handlePlanModeChange: vi.fn(),
isAgentBusy: false,
isWorking: true,
supportsSteering: false,
isStarting: false,
isPreparingEnvironment: false,
inputMode: "direct",
isQueueReady: true,
pendingClarification: null,
pendingCommentsByFile: {},
planComments: [],
pendingPRFeedback: [],
walkthroughComments: [],
messageComments: [],
chatSubmitKey: "enter",
agentCommands: [],
isFailed: false,
isCompleted: false,
needsRecovery: false,
contextItems: [],
planContextEnabled: false,
contextFiles: [],
handleToggleContextFile: vi.fn(),
handleAddContextFile: vi.fn(),
} as never,
composerWorkspaceId: null,
isMoving: false,
implementPlanHandler: undefined,
executor: { unavailable: false },
placeholder: "",
handleSubmit: vi.fn(),
handleCancelTurn: vi.fn().mockResolvedValue(undefined),
isSending: false,
showRequestChangesTooltip: false,
onClarificationResolved: vi.fn(),
};
}
describe("useComposerProps", () => {
it("forwards working state independently of queue admission", () => {
const { result } = renderHook(() => useComposerProps(composerArgs()));
expect((result.current as { isWorking?: boolean }).isWorking).toBe(true);
});
});
@@ -51,7 +51,8 @@ export function useComposerProps(args: ComposerPropsArgs) {
hideAgentControls,
hidePlanMode,
} = args;
const { resolvedSessionId, taskId, isAgentBusy, needsRecovery, planModeEnabled } = panelState;
const { resolvedSessionId, taskId, isAgentBusy, isWorking, needsRecovery, planModeEnabled } =
panelState;
const canQueueWhileStarting = panelState.inputMode === "queue" && panelState.isQueueReady;
const supportsSteering = panelState.supportsSteering;
const hasContextComments =
@@ -73,6 +74,7 @@ export function useComposerProps(args: ComposerPropsArgs) {
mcpAttachmentHistory: panelState.mcpAttachmentHistory,
onPlanModeChange: panelState.handlePlanModeChange,
isAgentBusy,
isWorking,
supportsSteering,
isStarting: panelState.isStarting,
canQueueWhileStarting,
@@ -95,6 +95,8 @@ export function PassthroughComposerPanel({
mcpAttachmentHistory={panelState.mcpAttachmentHistory}
onPlanModeChange={panelState.handlePlanModeChange}
isAgentBusy={false}
isWorking={panelState.isWorking}
showCancelAgent={false}
isCompleted={panelState.isCompleted}
isStarting={panelState.isStarting}
isPreparingEnvironment={panelState.isPreparingEnvironment}
@@ -462,6 +462,7 @@ describe("PassthroughToolbar – touch-scroll activation", () => {
// Composer open / close
// ---------------------------------------------------------------------------
// eslint-disable-next-line max-lines-per-function -- composer dismissal cases share one toolbar harness.
describe("PassthroughToolbar – composer toggle", () => {
it("clicking Chat toggle opens and closes the composer", async () => {
renderToolbar();
@@ -504,6 +505,24 @@ describe("PassthroughToolbar – composer toggle", () => {
expect(props.contextFiles).toEqual([{ path: SRC_FILE, name: "foo.ts" }]);
});
it("keeps the passthrough cancel callback as composer dismissal only", async () => {
mockSessionState = "RUNNING";
renderToolbar();
await openComposer();
const props = latestChatInputProps();
expect(props.showCancelAgent).toBe(false);
(props.onCancel as () => void)();
await waitFor(() => expect(screen.queryByTestId(TID_COMPOSER)).toBeNull());
expect(mockSessionState).toBe("RUNNING");
expect(mockWsRequestFn).not.toHaveBeenCalledWith(
"agent.cancel",
expect.objectContaining({ session_id: SESSION_ID }),
expect.any(Number),
);
});
it("uses the passthrough-specific focus shortcut instead of the global slash shortcut", async () => {
mockKeyboardShortcuts = {
FOCUS_PASSTHROUGH_INPUT: { key: "y", modifiers: { ctrlOrCmd: true, shift: true } },
+102
View File
@@ -5,6 +5,7 @@ type E2EStoreWindow = Window & {
getState: () => {
taskSessions: { items: Record<string, Record<string, unknown>> };
tasks: { activeSessionId: string | null };
quickChat: { activeSessionId: string | null };
sessionAgentctl: { itemsBySessionId: Record<string, { status?: string }> };
setAvailableCommands: (sessionId: string, commands: AvailableCommand[]) => void;
setAuthState: (state: {
@@ -107,6 +108,28 @@ export async function waitForActiveSessionForegroundActivity(
);
}
export async function waitForActiveSessionSupportsSteering(
page: Page,
expected = true,
): Promise<void> {
await page.waitForFunction(
(expectedValue) => {
const store = (window as E2EStoreWindow).__KANDEV_E2E_STORE__;
if (!store) return false;
const state = store.getState();
const sessionId = state.tasks.activeSessionId;
return sessionId
? state.taskSessions.items[sessionId]?.supports_steering === expectedValue
: false;
},
expected,
{
timeout: 15_000,
message: "Active session did not negotiate the expected steering capability",
},
);
}
/** Seed the stale activity projection that can survive a missed reconnect event. */
export async function seedActiveSessionForegroundActivity(
page: Page,
@@ -150,6 +173,85 @@ export async function waitForActiveSessionCancellationPending(
);
}
export async function waitForActiveQuickChatSupportsSteering(
page: Page,
expected = true,
): Promise<string> {
await page.waitForFunction(
(expectedValue) => {
const store = (window as E2EStoreWindow).__KANDEV_E2E_STORE__;
if (!store) return false;
const state = store.getState();
const sessionId = state.quickChat.activeSessionId;
return (
sessionId !== null &&
state.taskSessions.items[sessionId]?.supports_steering === expectedValue
);
},
expected,
{ timeout: 20_000, message: "Quick Chat did not negotiate the expected steering capability" },
);
return page.evaluate(() => {
const sessionId = (window as E2EStoreWindow).__KANDEV_E2E_STORE__?.getState().quickChat
.activeSessionId;
if (!sessionId) throw new Error("Quick Chat has no active session");
return sessionId;
});
}
export async function waitForActiveQuickChatForegroundActivity(
page: Page,
activity: "generating" | "background" | null,
): Promise<void> {
await page.waitForFunction(
(expected) => {
const store = (window as E2EStoreWindow).__KANDEV_E2E_STORE__;
if (!store) return false;
const state = store.getState();
const sessionId = state.quickChat.activeSessionId;
if (!sessionId) return false;
const current = state.taskSessions.items[sessionId]?.foreground_activity;
return expected === null ? current == null : current === expected;
},
activity,
{ timeout: 20_000, message: "Quick Chat foreground activity did not reach the expected state" },
);
}
export async function waitForQuickChatCancellationPending(
page: Page,
sessionId: string,
pending: boolean,
): Promise<void> {
await page.waitForFunction(
({ expected, sid }) => {
const store = (window as E2EStoreWindow).__KANDEV_E2E_STORE__;
return store?.getState().taskSessions.items[sid]?.cancellation_pending === expected;
},
{ expected: pending, sid: sessionId },
{ timeout: 20_000, message: `Quick Chat cancellation_pending did not become ${pending}` },
);
}
export async function waitForQuickChatSessionSettled(page: Page, sessionId: string): Promise<void> {
await page.waitForFunction(
(sid) => {
const store = (window as E2EStoreWindow).__KANDEV_E2E_STORE__;
const session = store?.getState().taskSessions.items[sid];
if (!session) return false;
return (
["IDLE", "WAITING_FOR_INPUT", "COMPLETED", "FAILED", "CANCELLED"].includes(
String(session.state),
) &&
session.cancellation_pending === false &&
session.foreground_activity == null
);
},
sessionId,
{ timeout: 20_000, message: `Quick Chat session ${sessionId} did not settle` },
);
}
/**
* Simulate a lean session-list / partial WS update: preserve `is_passthrough`
* but drop `agent_profile_snapshot` from the client store.
+5 -6
View File
@@ -10,17 +10,16 @@ import { expect, type Locator, type Page } from "@playwright/test";
* the toolbar has re-rendered into queue mode, which is why these call sites
* used to chase it with a fixed sleep.
*
* The cancel button is the rendered signal of that second derivation, so
* waiting on it gives the same guarantee reactively: it returns immediately
* when the composer is already busy and keeps waiting when the store is slow,
* instead of spending a fixed budget that is simultaneously too long and too
* short.
* The cancel button is also available while direct input is working, so it is
* not a queue-mode signal. Wait on the explicit queue-derived input mode,
* scoped to the visible composer in the supplied surface.
*/
export async function waitForComposerQueueMode(
scope: Page | Locator,
timeout = 15_000,
): Promise<void> {
await expect(scope.getByTestId("cancel-agent-button")).toBeVisible({ timeout });
const activeComposer = scope.locator('[data-testid="chat-input-area"]:visible');
await expect(activeComposer).toHaveAttribute("data-input-mode", "queue", { timeout });
}
/**
@@ -39,7 +39,7 @@ test.describe("Cancel progress across task switches", () => {
await activeCancel.click();
await waitForActiveSessionCancellationPending(testPage, true);
await expect(activeCancel).toBeDisabled();
await expect(activeCancel.getByRole("status", { name: "Loading" })).toBeVisible();
await expect(activeCancel.getByRole("status", { name: "Cancelling..." })).toBeVisible();
await expect(session.sidebar.getByText("Cancel progress B", { exact: true })).toBeVisible({
timeout: 15_000,
@@ -56,7 +56,7 @@ test.describe("Cancel progress across task switches", () => {
const remountedCancel = session.activeChat().getByTestId("cancel-agent-button");
await expect(remountedCancel).toBeVisible({ timeout: 15_000 });
await expect(remountedCancel).toBeDisabled();
await expect(remountedCancel.getByRole("status", { name: "Loading" })).toBeVisible();
await expect(remountedCancel.getByRole("status", { name: "Cancelling..." })).toBeVisible();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
await waitForActiveSessionCancellationPending(testPage, false);
@@ -0,0 +1,82 @@
import { test, expect } from "../../fixtures/test-base";
import {
waitForActiveSessionCancellationPending,
waitForActiveSessionForegroundActivity,
waitForActiveSessionSupportsSteering,
} from "../../helpers/session-store";
import { seedIdleSession } from "../../helpers/session";
import { seedRunningGeneratingSession } from "../../helpers/generating-session";
test.describe.serial("Cancel turn availability", () => {
test.describe.configure({ retries: 1 });
test.beforeAll(async ({ backend }) => {
await backend.restart({
KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF: "true",
KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING: "true",
});
});
test.afterAll(async ({ backend }) => {
await backend.restart();
});
test("keeps the direct input cancel control available during a steerable turn", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { session } = await seedRunningGeneratingSession(
testPage,
apiClient,
seedData,
"Direct input cancellation availability",
);
await waitForActiveSessionSupportsSteering(testPage);
await expect(session.activeChat().getByTestId("cancel-agent-button")).toBeVisible();
await expect(session.activeChat().getByTestId("submit-message-button")).toBeVisible();
await session.activeChat().getByTestId("cancel-agent-button").click();
await waitForActiveSessionCancellationPending(testPage, true);
await expect(session.activeChat().getByTestId("cancel-agent-button")).toBeDisabled();
await expect(session.idleInput()).toBeVisible({ timeout: 15_000 });
await waitForActiveSessionCancellationPending(testPage, false);
await waitForActiveSessionForegroundActivity(testPage, null);
await expect(session.activeChat().getByTestId("cancel-agent-button")).not.toBeVisible({
timeout: 15_000,
});
});
test("keeps the cancel control available while detached background work runs", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const session = await seedIdleSession(
testPage,
apiClient,
seedData,
"Background input cancellation availability",
);
await session.sendMessage("/detached-background 20s");
await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 });
await expect(session.idleInput()).toBeVisible({ timeout: 20_000 });
await waitForActiveSessionForegroundActivity(testPage, "background");
await expect(session.activeChat().getByTestId("cancel-agent-button")).toBeVisible();
await expect(session.activeChat().getByTestId("submit-message-button")).toBeVisible();
await session.activeChat().getByTestId("cancel-agent-button").click();
await waitForActiveSessionCancellationPending(testPage, true);
await expect(session.activeChat().getByTestId("cancel-agent-button")).toBeDisabled();
await expect(session.idleInput()).toBeVisible({ timeout: 15_000 });
await waitForActiveSessionCancellationPending(testPage, false);
await waitForActiveSessionForegroundActivity(testPage, null);
await expect(session.activeChat().getByTestId("cancel-agent-button")).not.toBeVisible({
timeout: 15_000,
});
});
});
@@ -18,7 +18,7 @@ test.describe("Mobile cancel progress across reloads", () => {
await cancel.tap();
await waitForActiveSessionCancellationPending(testPage, true);
await expect(cancel).toBeDisabled();
await expect(cancel.getByRole("status", { name: "Loading" })).toBeVisible();
await expect(cancel.getByRole("status", { name: "Cancelling..." })).toBeVisible();
await testPage.reload();
await session.waitForLoad();
@@ -26,7 +26,7 @@ test.describe("Mobile cancel progress across reloads", () => {
const reloadedCancel = session.activeChat().getByTestId("cancel-agent-button");
await expect(reloadedCancel).toBeVisible({ timeout: 15_000 });
await expect(reloadedCancel).toBeDisabled();
await expect(reloadedCancel.getByRole("status", { name: "Loading" })).toBeVisible();
await expect(reloadedCancel.getByRole("status", { name: "Cancelling..." })).toBeVisible();
await expect(session.idleInput()).toBeVisible({ timeout: 30_000 });
await waitForActiveSessionCancellationPending(testPage, false);
@@ -0,0 +1,67 @@
// Filename starts with "mobile-" so this runs on the mobile-chrome project.
import { test, expect } from "../../fixtures/test-base";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import {
waitForActiveSessionCancellationPending,
waitForActiveSessionForegroundActivity,
} from "../../helpers/session-store";
import { seedIdleSession } from "../../helpers/session";
test.describe.serial("Mobile cancel turn availability", () => {
test.beforeAll(async ({ backend }) => {
await backend.restart({ KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF: "true" });
});
test.afterAll(async ({ backend }) => {
await backend.restart();
});
test("keeps the background cancel target touch-sized and reachable", async ({
testPage,
apiClient,
seedData,
prCapture,
}) => {
test.setTimeout(120_000);
const session = await seedIdleSession(
testPage,
apiClient,
seedData,
"Mobile background cancellation availability",
);
await session.sendMessageViaButton("/detached-background 20s");
await expect(session.agentStatus()).toBeVisible({ timeout: 20_000 });
await expect(session.idleInput()).toBeVisible({ timeout: 20_000 });
await waitForActiveSessionForegroundActivity(testPage, "background");
const chat = session.activeChat();
const cancel = chat.getByTestId("cancel-agent-button");
await expect(cancel).toBeVisible();
const [cancelBox, composerBox] = await Promise.all([
cancel.boundingBox(),
chat.getByTestId("chat-input-area").boundingBox(),
]);
expect(cancelBox).not.toBeNull();
expect(composerBox).not.toBeNull();
expect(cancelBox!.width).toBeGreaterThanOrEqual(44);
expect(cancelBox!.height).toBeGreaterThanOrEqual(44);
expect(cancelBox!.y).toBeGreaterThanOrEqual(composerBox!.y);
expect(cancelBox!.y + cancelBox!.height).toBeLessThanOrEqual(
composerBox!.y + composerBox!.height + 1,
);
await prCapture.screenshot("mobile-cancel-turn-availability", {
caption: "Mobile background work keeps the cancel control reachable in the composer",
});
await cancel.tap();
await waitForActiveSessionCancellationPending(testPage, true);
await expect(cancel).toBeDisabled();
await expect(session.idleInput()).toBeVisible({ timeout: 20_000 });
await waitForActiveSessionCancellationPending(testPage, false);
await waitForActiveSessionForegroundActivity(testPage, null);
await expect(cancel).not.toBeVisible({ timeout: 15_000 });
await assertNoDocumentHorizontalOverflow(testPage, "mobile background cancellation");
});
});
@@ -0,0 +1,97 @@
// Filename starts with "mobile-" so this runs on the mobile-chrome project.
import { type Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions";
import {
waitForActiveQuickChatForegroundActivity,
waitForActiveQuickChatSupportsSteering,
waitForQuickChatCancellationPending,
waitForQuickChatSessionSettled,
} from "../../helpers/session-store";
import {
sendQuickChatMessage,
startQuickChatFromSetup,
waitForQuickChatDirectInput,
} from "./quick-chat-helpers";
function commandDialog(page: Page) {
return page.locator('[role="dialog"]:has([cmdk-input])').last();
}
test.describe.serial("Mobile Quick Chat cancellation", () => {
test.describe.configure({ retries: 1 });
test.beforeAll(async ({ backend }) => {
await backend.restart({
KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF: "true",
KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING: "true",
});
});
test.afterAll(async ({ backend }) => {
await backend.restart();
});
test("cancels an active Quick Chat turn through the visible touch composer control", async ({
testPage,
}) => {
test.setTimeout(120_000);
await testPage.goto("/");
await testPage.getByTestId("mobile-topbar-menu").tap();
await testPage.getByTestId("mobile-quick-chat-button").tap();
const quickChat = testPage.getByRole("dialog", { name: "Quick Chat" });
await startQuickChatFromSetup(quickChat, testPage);
await sendQuickChatMessage(quickChat, testPage, "/slow 30s");
const quickSessionId = await waitForActiveQuickChatSupportsSteering(testPage);
await waitForActiveQuickChatForegroundActivity(testPage, "generating");
const editor = quickChat.locator('.tiptap.ProseMirror[contenteditable="true"]:visible');
await expect(editor).toHaveText("");
await expect(quickChat.getByTestId("queue-chip")).not.toBeVisible();
const cancel = quickChat.getByTestId("cancel-agent-button");
await expect(cancel).toBeVisible();
expect((await cancel.boundingBox())!.height).toBeGreaterThanOrEqual(44);
await cancel.tap();
await waitForQuickChatCancellationPending(testPage, quickSessionId, true);
await expect(cancel).toBeDisabled();
await waitForQuickChatSessionSettled(testPage, quickSessionId);
await expect(cancel).not.toBeVisible({ timeout: 15_000 });
});
test("mobile Quick Chat exposes one touch cancellation command", async ({
testPage,
prCapture,
}) => {
test.setTimeout(120_000);
await testPage.goto("/");
await testPage.getByTestId("mobile-topbar-menu").tap();
await testPage.getByTestId("mobile-quick-chat-button").tap();
const quickChat = testPage.getByRole("dialog", { name: "Quick Chat" });
await startQuickChatFromSetup(quickChat, testPage);
await sendQuickChatMessage(quickChat, testPage, "/slow 30s");
await expect(
quickChat.getByRole("status", { name: /Agent is (starting|running)/ }),
).toBeVisible({
timeout: 15_000,
});
await testPage.keyboard.press("Control+k");
const palette = commandDialog(testPage);
await expect(palette).toBeVisible({ timeout: 10_000 });
await palette.getByRole("combobox").fill("cancel");
const cancelOption = palette.getByRole("option").filter({ hasText: /Cancel Turn/i });
await expect(cancelOption).toHaveCount(1);
expect((await cancelOption.boundingBox())!.height).toBeGreaterThanOrEqual(44);
await cancelOption.tap();
await expect(quickChat.getByTestId("cancel-agent-button")).toBeDisabled({ timeout: 5_000 });
await waitForQuickChatDirectInput(quickChat);
await prCapture.screenshot("mobile-quick-chat-cancel-palette", {
caption: "Quick Chat cancellation remains available through the touch-sized command palette",
});
await assertNoDocumentHorizontalOverflow(testPage, "mobile Quick Chat cancellation palette");
});
});
@@ -0,0 +1,126 @@
import { type Page } from "@playwright/test";
import { test, expect } from "../../fixtures/test-base";
import {
waitForActiveQuickChatForegroundActivity,
waitForActiveQuickChatSupportsSteering,
waitForQuickChatCancellationPending,
waitForQuickChatSessionSettled,
waitForActiveSessionForegroundActivity,
} from "../../helpers/session-store";
import { seedRunningGeneratingSession } from "../../helpers/generating-session";
import {
openQuickChatWithAgent,
sendQuickChatMessage,
waitForQuickChatDirectInput,
} from "./quick-chat-helpers";
function commandDialog(page: Page) {
return page.locator('[role="dialog"]:has([cmdk-input])').last();
}
test.describe.serial("Quick Chat cancellation palette and composer", () => {
test.describe.configure({ retries: 1 });
test.beforeAll(async ({ backend }) => {
await backend.restart({
KANDEV_FEATURES_CLAUDE_BACKGROUND_PROMPT_HANDOFF: "true",
KANDEV_FEATURES_CLAUDE_MID_TURN_STEERING: "true",
});
});
test.afterAll(async ({ backend }) => {
await backend.restart();
});
test("cancels an active Quick Chat turn from its empty composer", async ({ testPage }) => {
test.setTimeout(120_000);
const quickChat = await openQuickChatWithAgent(testPage);
await sendQuickChatMessage(quickChat, testPage, "/slow 30s");
const quickSessionId = await waitForActiveQuickChatSupportsSteering(testPage);
await waitForActiveQuickChatForegroundActivity(testPage, "generating");
const editor = quickChat.locator('.tiptap.ProseMirror[contenteditable="true"]:visible');
await expect(editor).toHaveText("");
await expect(quickChat.getByTestId("queue-chip")).not.toBeVisible();
const cancel = quickChat.getByTestId("cancel-agent-button");
await expect(cancel).toBeVisible();
await cancel.click();
await waitForQuickChatCancellationPending(testPage, quickSessionId, true);
await expect(cancel).toBeDisabled();
await waitForQuickChatSessionSettled(testPage, quickSessionId);
await expect(cancel).not.toBeVisible({ timeout: 15_000 });
});
test("cancels Quick Chat while detached background work runs", async ({ testPage }) => {
test.setTimeout(120_000);
const quickChat = await openQuickChatWithAgent(testPage);
await sendQuickChatMessage(quickChat, testPage, "/detached-background 20s");
const quickSessionId = await testPage.evaluate(() => {
const store = (
window as Window & {
__KANDEV_E2E_STORE__?: {
getState: () => { quickChat: { activeSessionId: string | null } };
};
}
).__KANDEV_E2E_STORE__;
const sessionId = store?.getState().quickChat.activeSessionId;
if (!sessionId) throw new Error("Quick Chat has no active session");
return sessionId;
});
await waitForActiveQuickChatForegroundActivity(testPage, "background");
await expect(
quickChat.locator('.tiptap.ProseMirror[contenteditable="true"]:visible'),
).toHaveText("");
await expect(quickChat.getByTestId("queue-chip")).not.toBeVisible();
const cancel = quickChat.getByTestId("cancel-agent-button");
await expect(cancel).toBeVisible();
await cancel.click();
await waitForQuickChatCancellationPending(testPage, quickSessionId, true);
await expect(cancel).toBeDisabled();
await waitForQuickChatSessionSettled(testPage, quickSessionId);
await expect(cancel).not.toBeVisible({ timeout: 15_000 });
});
test("cancels Quick Chat and leaves the underlying task turn running", async ({
testPage,
apiClient,
seedData,
}) => {
test.setTimeout(120_000);
const { session } = await seedRunningGeneratingSession(
testPage,
apiClient,
seedData,
"Quick Chat palette cancellation target",
);
const underlyingCancel = session.activeChat().getByTestId("cancel-agent-button");
await expect(underlyingCancel).toBeVisible();
const quickChat = await openQuickChatWithAgent(testPage, false);
await sendQuickChatMessage(quickChat, testPage, "/slow 30s");
await expect(
quickChat.getByRole("status", { name: /Agent is (starting|running)/ }),
).toBeVisible({ timeout: 15_000 });
const modifier = process.platform === "darwin" ? "Meta" : "Control";
await testPage.keyboard.press(`${modifier}+k`);
const palette = commandDialog(testPage);
await expect(palette).toBeVisible({ timeout: 10_000 });
await palette.getByRole("combobox").fill("cancel");
const cancelOption = palette.getByRole("option").filter({ hasText: /Cancel Turn/i });
await expect(cancelOption).toHaveCount(1);
await cancelOption.click();
await expect(quickChat.getByTestId("cancel-agent-button")).toBeDisabled({ timeout: 5_000 });
await waitForQuickChatDirectInput(quickChat);
await waitForActiveSessionForegroundActivity(testPage, "generating");
await testPage.keyboard.press("Escape");
await expect(quickChat).not.toBeVisible();
await expect(underlyingCancel).toBeVisible({ timeout: 10_000 });
await expect(underlyingCancel).toBeEnabled();
});
});
@@ -1,6 +1,5 @@
import { test, expect } from "../../fixtures/test-base";
import type { ApiClient } from "../../helpers/api-client";
import { KanbanPage } from "../../pages/kanban-page";
import { SessionPage } from "../../pages/session-page";
/**
@@ -47,7 +46,7 @@ test.describe("Session resume — CLI fallback after fast-fail", () => {
const profile = await createTUIProfileWithFailOnResume(apiClient, "TUI Resume Fallback");
await apiClient.createTaskWithAgent(
const task = await apiClient.createTaskWithAgent(
seedData.workspaceId,
"TUI Resume Fallback Task",
profile.id,
@@ -59,12 +58,10 @@ test.describe("Session resume — CLI fallback after fast-fail", () => {
},
);
const kanban = new KanbanPage(testPage);
await kanban.goto();
const card = kanban.taskCardByTitle("TUI Resume Fallback Task");
await expect(card).toBeVisible({ timeout: 15_000 });
await card.click();
// Navigate by the API-created task ID. The kanban list is refreshed through
// a separate websocket path and can lag behind task creation under a busy
// CI shard, while the task route can load the same persisted task directly.
await testPage.goto(`/t/${task.id}`);
await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
const session = new SessionPage(testPage);