fix: restore cancel controls during active turns (#3705)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } },
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
---
|
||||
created: 2026-09-15
|
||||
status: complete
|
||||
requirements:
|
||||
- REQ-UI-CANCEL-TURN-PROGRESS-001
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/cancel-turn-availability.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Cancel control availability
|
||||
|
||||
## Overview
|
||||
|
||||
Restore cancellation for steering and background-working sessions in task and
|
||||
Quick Chat composers. Then add a correctly scoped Quick Chat palette fallback.
|
||||
[Issue #3700](https://github.com/kdlbs/kandev/issues/3700) is assigned to
|
||||
`carlosflorencio`. Implementation was explicitly requested on 2026-09-15.
|
||||
|
||||
## Evidence and root cause
|
||||
|
||||
Read-only source trace at `6c5ab15980ab3d39660134073d724addae9c8579` confirms the
|
||||
reported failure. The issue has no image attachments or comments. No live
|
||||
instance, private logs, or temporary test was needed for this deterministic trace.
|
||||
|
||||
| Input / source | Result |
|
||||
| --- | --- |
|
||||
| RUNNING, generating, supports_steering=true, empty queue | `deriveSessionInputMode` returns direct |
|
||||
| `deriveSessionFlags` | isAgentBusy=false; isWorking=true |
|
||||
| `useComposerProps` | forwards isAgentBusy; omits isWorking |
|
||||
| `buildEditorAreaProps` | calls shouldShowCancelAgent(false, null), returning false |
|
||||
| `SubmitButton` | no cancel control; empty-input send remains visible |
|
||||
| RUNNING, background | same mismatch, independent of steering |
|
||||
| STARTING | queue and working are both true; cancel is already offered |
|
||||
| QuickChatContent | cancellation handler exists, but no session command registration |
|
||||
| SessionCommands / CommandRegistryProvider | task registration only; sources concatenate without deduplication |
|
||||
|
||||
Smallest reproduction: open a structured Quick Chat with a steering-capable
|
||||
agent, start a long turn, leave the queue and editor empty, and inspect cancel.
|
||||
Repeat with background activity. The current source deterministically hides it.
|
||||
Runtime/browser reproduction and permanent regression tests are implementation
|
||||
work, not claimed as completed investigation evidence.
|
||||
|
||||
The gap extends existing AC-UI-CANCEL-TURN-PROGRESS-001.7 and .8. Criteria .9-.12
|
||||
make availability, clarification compatibility, palette scope, and mobile
|
||||
reachability explicit. No requirement or design conflict blocks this repair.
|
||||
STARTING remains cancellable because that is current behavior. The package
|
||||
preserves connected/disconnected clarification rules and existing progress.
|
||||
|
||||
## Scope
|
||||
|
||||
### In scope
|
||||
|
||||
- Shared composer availability, including Quick Chat configuration conversations.
|
||||
- Quick Chat cancel-only palette registration and task-cancel suppression.
|
||||
- Queue helper correction and focused desktop/phone regression coverage.
|
||||
|
||||
### Out of scope
|
||||
|
||||
- Backend cancellation, provider negotiation, prompt ordering, or flag changes.
|
||||
- Passthrough terminal gestures and full task command sets in Quick Chat.
|
||||
- Cancellation-progress persistence and unrelated command palette actions.
|
||||
|
||||
## Technical approach
|
||||
|
||||
Task 01 passes working state through `use-composer-props.ts` into
|
||||
`chat-input-container.tsx`, keeps the clarification override, and verifies
|
||||
`chat-input-toolbar-primitives.tsx` receives independent cancel/send signals.
|
||||
Use a required prop and update all direct callers/test fixtures; do not silently
|
||||
fall back to `isAgentBusy`. Add the localized accessible name to the cancel icon.
|
||||
Passthrough keeps its Escape callback as composer dismissal and explicitly hides
|
||||
the agent cancel control because that surface has no agent-cancel transport.
|
||||
|
||||
Task 02 adds `quick-chat-cancel-commands.tsx` beside `quick-chat-content.tsx`.
|
||||
Reuse `buildSessionCommands` and the existing callback. Suppress task cancellation
|
||||
while Quick Chat is open. Prove tab switching, closed state, idle/setup/terminal
|
||||
state, and an underlying running task cannot create an ambiguous cancel target.
|
||||
|
||||
No new ADR is needed: this restores the established session-scoped cancellation
|
||||
contract without changing runtime ownership. The earlier
|
||||
[cancel progress package](../cancel-turn-progress/plan.md) remains completed;
|
||||
this package supplements its coverage without rewriting historical results.
|
||||
The [steering package](../mid-turn-steering/plan.md) retains its delivery contract.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-01: Task or Quick Chat composer, generating with steering, empty editor.
|
||||
Shared control order on desktop and phone:
|
||||
|
||||
```text
|
||||
Before: [Send now; delivered to the running turn] [Send]
|
||||
After: [Send now; delivered to the running turn] [Cancel][Send]
|
||||
Pending:[Send now; delivered to the running turn] [Busy ][Send]
|
||||
Idle: [Message input ] [Send]
|
||||
```
|
||||
|
||||
UI-02: Command panel while Quick Chat is open:
|
||||
|
||||
```text
|
||||
Search: cancel
|
||||
Agent
|
||||
Cancel turn -> active Quick Chat session
|
||||
```
|
||||
|
||||
Cancel precedes Send in the existing right-hand action group. Send keeps its
|
||||
current empty-input disabled state. Busy is the existing disabled spinner, not
|
||||
new copy. Background work uses ordinary direct-input copy. Non-steering empty
|
||||
queue-mode input retains the existing cancel-only action group.
|
||||
|
||||
Phone uses the existing compact bottom toolbar with 44px hitboxes; desktop uses
|
||||
28px icons. Both keep the editor above the action row and messages as the scroll
|
||||
owner. The phone control must remain reachable with the keyboard visible and
|
||||
without horizontal overflow. No additional sheet is needed for a frequent action.
|
||||
These control order and reachability requirements are structural; spacing and
|
||||
labels in the sketch are illustrative and use existing translations.
|
||||
UI-01 maps to criteria .7-.10 and .12; UI-02 maps to .11.
|
||||
|
||||
## Tests
|
||||
|
||||
- Task 01: container prop integration must fail before the fix for
|
||||
`isWorking=true, isAgentBusy=false`. Cover steering, background, STARTING,
|
||||
preparation, idle, missing session, connected and disconnected clarification.
|
||||
- Existing toolbar tests cover pending state and cancellation callback. Assert
|
||||
independent send behavior, a localized accessible name, session isolation, and
|
||||
passthrough dismissal without agent cancellation.
|
||||
- Task 02: mount `SessionCommands` and the Quick Chat source together and assert
|
||||
zero or one cancel entry and the exact callback/session target after tab
|
||||
switches, close/unmount, terminal/setup/idle/disconnected selection, backend
|
||||
pending rejection, and failed-request cleanup.
|
||||
|
||||
## E2E tests
|
||||
|
||||
Task 01 owns `cancel-turn-availability.spec.ts` and
|
||||
`mobile-cancel-turn-availability.spec.ts` under `apps/web/e2e/tests/chat/`.
|
||||
Cover task steering and background work with empty input/queue; click/tap cancel
|
||||
and observe the selected session settle. Preserve direct submission. Use
|
||||
existing generating-session and parked-background fixtures. Retain cancellation
|
||||
reload checks and steering/queue delivery checks.
|
||||
|
||||
Task 02 owns `quick-chat-cancel-palette.spec.ts` and
|
||||
`mobile-quick-chat-cancel-palette.spec.ts` in the same directory. The desktop
|
||||
and phone suites now exercise negotiated Quick Chat steering with generating
|
||||
activity, an empty editor and queue, then click/tap the visible composer cancel
|
||||
control and wait for the exact Quick Chat session to settle. Desktop also covers
|
||||
detached background work. The palette cases prove exact session targeting with
|
||||
a running task beneath Quick Chat, and retain the existing phone palette entry.
|
||||
All mobile files run in `mobile-chrome`; other files run in `chromium`.
|
||||
Arm causal transport waits before actions and assert session outcomes afterward.
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Restore shared cancellation availability](task-01-composer-availability.md) - complete
|
||||
- [x] [Task 02: Scope Quick Chat palette cancellation](task-02-quick-chat-palette.md) - complete
|
||||
|
||||
Sequential execution; Task 02 depends on Task 01's eligibility wiring.
|
||||
|
||||
## Verification results
|
||||
|
||||
Planning validation on 2026-09-15:
|
||||
|
||||
- `python3 scripts/list-docs.py validate`: passed (272 decisions, 937 specifications).
|
||||
- `python3 scripts/lint-spec-files.py --all`: passed.
|
||||
- `git diff --check -- docs/specs docs/plans/cancel-turn-availability`: passed.
|
||||
|
||||
Implementation validation on 2026-09-15:
|
||||
|
||||
- RED regressions were observed before the production changes: direct working
|
||||
input produced `canCancelAgent=false`, the composer hook omitted `isWorking`,
|
||||
the toolbar had no cancel accessible name, and the task command remained
|
||||
registered while Quick Chat was open. The later review found that the
|
||||
underlying-command claim had only been covered by a builder test; no RED
|
||||
integration result is claimed for that earlier test.
|
||||
- Review remediation added a passthrough callback regression and an actual
|
||||
registry integration matrix with both command sources mounted. It covers
|
||||
active A-to-B switching, close/unmount restoration, setup/terminal/idle and
|
||||
disconnected clarification suppression, backend-pending rejection, and
|
||||
failed-request cleanup.
|
||||
- Affected frontend unit suite: 9 files, 128 tests passed, including the
|
||||
review remediation coverage for the changed composer, Quick Chat registry,
|
||||
spinner semantics, and eligibility paths.
|
||||
- `pnpm run build:vite`: passed. Existing Vite chunk-size and dynamic-import
|
||||
warnings remain informational.
|
||||
- `pnpm run typecheck`: passed.
|
||||
- Targeted ESLint for all changed frontend and E2E files: passed.
|
||||
- Targeted E2E sleep lint for changed E2E files: passed.
|
||||
- `pnpm run i18n:check`: passed; the existing 200 orphan catalog warning remains.
|
||||
- Chromium cancellation, queue, steering, and cancellation-progress coverage:
|
||||
28 tests passed.
|
||||
- Chromium Quick Chat composer and palette coverage: 3 tests passed, including
|
||||
negotiated steering, detached background work, empty editor/queue evidence,
|
||||
exact Quick Chat settle, and underlying-task preservation.
|
||||
- Mobile cancellation, reload progress, and Quick Chat composer/palette
|
||||
coverage: 4 tests passed, including touch-only composer cancellation with a
|
||||
44px control and exact Quick Chat settle.
|
||||
- `python3 scripts/list-docs.py validate`: passed (272 decisions, 937 specifications).
|
||||
- `python3 scripts/lint-spec-files.py --all`: passed.
|
||||
- `git diff --check`: passed.
|
||||
|
||||
Post-remediation managed E2E validation for PR #3705:
|
||||
|
||||
- Chromium: 5 tests passed, covering task steering/background cancellation and
|
||||
Quick Chat composer cancellation, background cancellation, and preservation
|
||||
of the underlying task turn.
|
||||
- Mobile Chrome: 3 tests passed, covering task touch cancellation, Quick Chat
|
||||
touch composer cancellation, and the Quick Chat palette command.
|
||||
|
||||
Review fixup and delivery validation for PR #3705:
|
||||
|
||||
- PR [#3705](https://github.com/kdlbs/kandev/pull/3705) is open on
|
||||
`feature/investigate-issue-37-922` and closes issue #3700.
|
||||
- Remediation commit `41882f3abe0011cf3112a014145496a760bb9cff` addresses all
|
||||
six inline review threads. Final test cleanup commit
|
||||
`96ff68d3f5b35704065e3aa43fefcf383f8a4e92` adds the enabled-state assertion
|
||||
for the underlying task cancel control.
|
||||
- The required 15-minute post-creation wait was completed. Review fixup
|
||||
replies were added and all six threads are resolved on the latest PR head.
|
||||
- Fresh active-composer desktop and Pixel 5 screenshots are published from
|
||||
media commit `125ef91d16b292d103f60fba714c17929403df35`.
|
||||
- The final desktop Quick Chat suite passed all 3 tests after the additional
|
||||
assertion. The local worktree is clean and the final branch head is pushed
|
||||
in docs-results commit `3d50b35a4a0b2820d09f7beb16eb1a01ac0b395c`.
|
||||
- At implementation head `96ff68d3f5b35704065e3aa43fefcf383f8a4e92`,
|
||||
authenticated connector evidence reported no failed workflow runs; Backend
|
||||
Tests and E2E Tests were queued or pending, while Frontend Tests and Preview
|
||||
Environment were in progress. The later docs-only push reset that workflow
|
||||
rollup. This historical snapshot was pending or unknown at the time and is
|
||||
superseded by the CI remediation results below.
|
||||
|
||||
CI remediation results on 2026-09-16:
|
||||
|
||||
- CI reproduced two deterministic stale assertions after the cancellation
|
||||
status was made accessible: the task-switch and mobile-reload regressions
|
||||
still expected the generic `Loading` status while the UI exposed the
|
||||
translated `Cancelling...` status. Both now assert the cancellation status.
|
||||
- CI also exposed a retry-only Kanban propagation race in
|
||||
`session-resume-cli-fallback.spec.ts`. The test now navigates directly to the
|
||||
API-created task route, avoiding a separate websocket list refresh while
|
||||
retaining the persisted-session assertions.
|
||||
- The latest `main` tip `02120b907c68f243b4f70f34b73d9fffef2a6385` was merged
|
||||
into the branch in merge commit `145b39f89031cc66d4bb9b614a705f99f009b6ae`.
|
||||
The E2E fix commit is `fb1cd3cecc`.
|
||||
- Local managed E2E validation passed: the desktop task-switch and resume
|
||||
regressions passed together, the mobile reload regression passed, and the
|
||||
resume regression passed three consecutive times.
|
||||
- PR #3705 is green at head `145b39f89031cc66d4bb9b614a705f99f009b6ae`:
|
||||
50 checks passed, including all 14 E2E shards, the E2E aggregate, frontend,
|
||||
backend, architecture, action-pinning, harness, and documentation coverage;
|
||||
zero checks failed or remained pending, and no review threads are unresolved.
|
||||
- The first documentation coverage publisher attempt failed without a
|
||||
validator error and was rerun successfully. No source change was needed for
|
||||
that transient workflow failure.
|
||||
|
||||
The repo-wide `pnpm run lint:e2e-sleeps` audit still reports unrelated baseline
|
||||
errors in other files, including missing rule definitions and existing
|
||||
unsanctioned sleeps. The changed E2E files pass the same configuration in the
|
||||
targeted check above.
|
||||
|
||||
## Risks
|
||||
|
||||
- Hook working state includes executor preparation; preserve session identity.
|
||||
- Disconnected clarifications must retain their explicit suppression.
|
||||
- Multiple palette sources can register identical IDs; priority is insufficient.
|
||||
- Queue waits must use the explicit input-mode projection rather than visual
|
||||
busy state, because cancellation is also available during direct input.
|
||||
- New mobile test filenames must use the mobile prefix to match the project.
|
||||
|
||||
## Documentation impact
|
||||
|
||||
Internal specifications and plans only in this turn. Existing cancellation
|
||||
copy and transport remain unchanged. Public docs do not need speculative
|
||||
instructions before implementation; implementation should recheck the public
|
||||
interaction guide if its documented palette behavior needs updating.
|
||||
@@ -0,0 +1,159 @@
|
||||
---
|
||||
id: "01-composer-availability"
|
||||
title: "Restore shared cancellation availability"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-CANCEL-TURN-PROGRESS-001
|
||||
acceptance_criteria:
|
||||
- AC-UI-CANCEL-TURN-PROGRESS-001.1
|
||||
- AC-UI-CANCEL-TURN-PROGRESS-001.4
|
||||
- AC-UI-CANCEL-TURN-PROGRESS-001.6
|
||||
- AC-UI-CANCEL-TURN-PROGRESS-001.7
|
||||
- AC-UI-CANCEL-TURN-PROGRESS-001.8
|
||||
- AC-UI-CANCEL-TURN-PROGRESS-001.9
|
||||
- AC-UI-CANCEL-TURN-PROGRESS-001.10
|
||||
- AC-UI-CANCEL-TURN-PROGRESS-001.12
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/cancel-turn-availability.md
|
||||
---
|
||||
|
||||
# Task 01: Restore shared cancellation availability
|
||||
|
||||
## Summary
|
||||
|
||||
Use working state for cancellation while preserving queue and steering behavior.
|
||||
Deliver desktop and phone evidence through the real composer prop path.
|
||||
|
||||
## In scope
|
||||
|
||||
- Pass required `isWorking` from `useComposerProps` to `ChatInputContainer`.
|
||||
- Retain clarification overrides in `shouldShowCancelAgent`; require session
|
||||
identity for an actionable control. Preserve existing preparation/startup.
|
||||
- Keep explicit `canCancelAgent` through body and both toolbars. Reuse existing
|
||||
localized copy for the cancel icon accessible name and existing progress state.
|
||||
- Replace `waitForComposerQueueMode`'s cancel-button wait with a scoped assertion
|
||||
on the explicit queue-derived `data-input-mode` projection.
|
||||
- Add focused unit and browser coverage, including empty-input steering and
|
||||
background work in task and Quick Chat, and phone reachability.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Palette registration, backend changes, queue ordering, and new cancellation copy.
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. A rendered integration regression named `shows cancel for a working direct-input
|
||||
session` fails before the wiring change in `chat-input-container.test.tsx`.
|
||||
Assert the body receives `canCancelAgent=true` and `isAgentBusy=false`; also
|
||||
prove `useComposerProps` actually forwards working state with a hook test.
|
||||
2. State-table tests cover steering, background, STARTING, preparation, idle,
|
||||
missing session, connected clarification, and disconnected clarification.
|
||||
Existing pending animation, retry eligibility, and send behavior remain intact.
|
||||
3. Task and Quick Chat browser tests click/tap the cancel control with no typed
|
||||
message and assert the selected session settles. Phone tests measure 44px
|
||||
targets, viewport containment, and no document horizontal overflow, and save
|
||||
a rendered screenshot for comparison with UI-01.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-01, shared desktop/phone action order ([full preview](plan.md#ascii-ui-preview)):
|
||||
|
||||
```text
|
||||
Working direct input: [Message editor] [Cancel][Send]
|
||||
Cancellation pending: [Message editor] [Busy ][Send]
|
||||
Fully idle: [Message editor] [Send]
|
||||
```
|
||||
|
||||
Criteria .7-.10 and .12 apply. Retain the phone bottom toolbar, 44px touch
|
||||
controls, existing keyboard/safe-area behavior, and the desktop 28px controls.
|
||||
|
||||
## Verification
|
||||
|
||||
Run from the repository root; install once for a fresh worktree.
|
||||
|
||||
```bash
|
||||
(cd apps && pnpm install --frozen-lockfile)
|
||||
(cd apps/web && pnpm exec vitest run components/task/chat/chat-input-container.test.tsx components/task/chat/use-composer-props.test.tsx components/task/chat/chat-input-body.test.tsx components/task/chat/chat-input-toolbar.test.tsx components/task/chat/chat-input-toolbar-composer.test.tsx hooks/domains/session/use-session-state.test.ts)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
(cd apps/web && pnpm exec eslint components/task/chat/use-composer-props.ts components/task/chat/chat-input-container.tsx components/task/chat/chat-input-toolbar-primitives.tsx e2e/helpers/type-while-busy.ts e2e/tests/chat/cancel-turn-availability.spec.ts e2e/tests/chat/mobile-cancel-turn-availability.spec.ts)
|
||||
(cd apps/web && pnpm run i18n:check)
|
||||
(cd apps/web && pnpm e2e:run --project chromium tests/chat/cancel-turn-availability.spec.ts tests/chat/mid-turn-steering.spec.ts tests/chat/message-queue.spec.ts tests/chat/cancel-progress-task-switch.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-cancel-turn-availability.spec.ts tests/chat/mobile-cancel-progress-reload.spec.ts)
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
Existing files:
|
||||
|
||||
- `apps/web/components/task/chat/use-composer-props.ts`
|
||||
- `apps/web/components/task/chat/chat-input-container.tsx`
|
||||
- `apps/web/components/task/chat/chat-input-container.test.tsx`
|
||||
- `apps/web/components/task/chat/chat-input-body.test.tsx`
|
||||
- `apps/web/components/task/chat/chat-input-toolbar-primitives.tsx`
|
||||
- `apps/web/components/task/chat/chat-input-toolbar.test.tsx`
|
||||
- `apps/web/components/task/chat/chat-input-toolbar-composer.test.tsx`
|
||||
- `apps/web/hooks/domains/session/use-session-state.test.ts`
|
||||
- `apps/web/e2e/helpers/type-while-busy.ts`
|
||||
|
||||
New files:
|
||||
|
||||
- `apps/web/components/task/chat/use-composer-props.test.tsx`
|
||||
- `apps/web/e2e/tests/chat/cancel-turn-availability.spec.ts`
|
||||
- `apps/web/e2e/tests/chat/mobile-cancel-turn-availability.spec.ts`
|
||||
|
||||
Update other direct `ChatInputContainer` callers/test fixtures only as required
|
||||
by the new required prop; enumerate them with `rg` before implementation.
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Risks
|
||||
|
||||
Preserve clarification suppression, pending-state session identity, and direct
|
||||
steering delivery. Palette sources do not deduplicate matching command IDs.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- [Requirements](../../specs/ui/requirements/cancel-turn-progress.md).
|
||||
- [Design](../../specs/ui/system-design/cancel-turn-availability.md).
|
||||
- [Plan and source-trace evidence](plan.md).
|
||||
|
||||
## Results
|
||||
|
||||
- RED: the direct working-input integration regression returned
|
||||
`canCancelAgent=false` before `isWorking` was wired through the composer;
|
||||
the hook, toolbar accessible-name, and state-table regressions also failed at
|
||||
their pre-fix boundaries.
|
||||
- GREEN: `isWorking` now travels from `useComposerProps` through
|
||||
`ChatInputContainer`, cancellation requires a session identity, and the
|
||||
clarification override remains intact. The toolbar uses the existing
|
||||
localized cancel label and keeps send and queue behavior independent.
|
||||
- Passthrough explicitly sets `showCancelAgent={false}` because its parent
|
||||
`onCancel` callback dismisses the composer. The parent-path regression proves
|
||||
the callback still closes the composer and cannot cancel the agent, including
|
||||
through the existing Escape dismissal.
|
||||
- The queue E2E helper now waits for the explicit queue-derived input-mode
|
||||
projection instead of using cancel visibility or a busy CSS class as queue
|
||||
evidence.
|
||||
- Review remediation keeps the cancel button's accessible name stable while
|
||||
exposing translated cancellation progress through its status spinner. The
|
||||
shared `shouldShowCancelAgent` predicate now belongs to the chat domain types.
|
||||
- Focused frontend suite passed with 9 files and 128 tests. Typecheck, i18n,
|
||||
targeted ESLint, targeted E2E sleep lint, Vite build, and the desktop and
|
||||
mobile cancellation regressions passed. Mobile coverage verified the 44px
|
||||
composer target, composer containment, screenshot capture, and no horizontal
|
||||
overflow. Quick Chat direct composer coverage is recorded in Task 02 because
|
||||
it shares the Quick Chat palette fixture.
|
||||
- CI follow-up corrected the existing desktop task-switch and mobile reload
|
||||
assertions to expect the translated `Cancelling...` status exposed by the
|
||||
cancellation spinner. Both passed in the PR E2E matrix, including all 14
|
||||
shards.
|
||||
@@ -0,0 +1,148 @@
|
||||
---
|
||||
id: "02-quick-chat-palette"
|
||||
title: "Scope Quick Chat palette cancellation"
|
||||
status: done
|
||||
wave: 2
|
||||
depends_on: ['01-composer-availability']
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-UI-CANCEL-TURN-PROGRESS-001
|
||||
acceptance_criteria:
|
||||
- AC-UI-CANCEL-TURN-PROGRESS-001.4
|
||||
- AC-UI-CANCEL-TURN-PROGRESS-001.9
|
||||
- AC-UI-CANCEL-TURN-PROGRESS-001.10
|
||||
- AC-UI-CANCEL-TURN-PROGRESS-001.11
|
||||
- AC-UI-CANCEL-TURN-PROGRESS-001.12
|
||||
system_design:
|
||||
- ../../specs/ui/system-design/cancel-turn-availability.md
|
||||
---
|
||||
|
||||
# Task 02: Scope Quick Chat palette cancellation
|
||||
|
||||
## Summary
|
||||
|
||||
Expose a localized cancel-only command for the active Quick Chat conversation.
|
||||
Ensure the command cannot target an underlying task or an inactive chat.
|
||||
|
||||
## In scope
|
||||
|
||||
- Add `quick-chat-cancel-commands.tsx`, mounted by `QuickChatContent`, with explicit
|
||||
session ID, eligibility, and the existing `handleCancelTurn` callback.
|
||||
- Reuse `buildSessionCommands` and `useRegisterCommands`. Scope registration to
|
||||
open Quick Chat, conversation kind, and matching active session ID.
|
||||
- Suppress the task `session-cancel` entry while Quick Chat is open. Restore it
|
||||
on close without suppressing unrelated task commands.
|
||||
- Apply the composer eligibility rule and guard dispatch while backend or
|
||||
optimistic cancellation is pending. Preserve cleanup across tab switching.
|
||||
- Prove exact callback/request identity and no duplicate cancellation entries.
|
||||
|
||||
## Out of scope
|
||||
|
||||
Registering task/git/panel actions inside Quick Chat; terminal cancellation or
|
||||
new palette state architecture; changing the command registry globally.
|
||||
|
||||
## Acceptance
|
||||
|
||||
1. Add a registry integration test named `targets only the active Quick Chat
|
||||
session over a running task`. Exercise actual `SessionCommands` and Quick
|
||||
Chat source registration together under `CommandRegistryProvider`, assert one
|
||||
cancel entry, and inspect its action.
|
||||
2. Cover A-to-B chat switching, closing Quick Chat, unmount, terminal/setup/idle
|
||||
content, disconnected clarification, and pending cancellation. No stale
|
||||
callback, duplicate entry, or underlying task cancel may remain active.
|
||||
3. Desktop and phone E2E open the command panel through their existing entry,
|
||||
invoke cancellation, and verify the exact session settles while the underlying
|
||||
task keeps running. Preserve the phone's visible composer cancel fallback.
|
||||
|
||||
## ASCII UI preview
|
||||
|
||||
UI-02, desktop and phone command result
|
||||
([full preview](plan.md#ascii-ui-preview), criterion .11):
|
||||
|
||||
```text
|
||||
Search: cancel
|
||||
Agent
|
||||
Cancel turn -> active Quick Chat session
|
||||
```
|
||||
|
||||
Idle/setup/terminal foreground content shows no session-cancel result.
|
||||
Use the existing responsive command-panel composition and touch controls.
|
||||
UI-01's composer remains the direct phone action; no keyboard is required.
|
||||
|
||||
## Verification
|
||||
|
||||
Run from the repository root, after Task 01's dependency installation.
|
||||
|
||||
```bash
|
||||
(cd apps/web && pnpm exec vitest run components/quick-chat/quick-chat-cancel-commands.test.tsx components/session-commands.test.tsx)
|
||||
(cd apps/web && pnpm run typecheck)
|
||||
(cd apps/web && pnpm exec eslint components/quick-chat/quick-chat-cancel-commands.tsx components/quick-chat/quick-chat-content.tsx components/session-commands.tsx e2e/tests/chat/quick-chat-cancel-palette.spec.ts e2e/tests/chat/mobile-quick-chat-cancel-palette.spec.ts)
|
||||
(cd apps/web && pnpm run i18n:check)
|
||||
(cd apps/web && pnpm e2e:run --project chromium tests/chat/quick-chat-cancel-palette.spec.ts tests/chat/cancel-turn-availability.spec.ts)
|
||||
(cd apps/web && pnpm e2e:run --project mobile-chrome tests/chat/mobile-quick-chat-cancel-palette.spec.ts tests/chat/mobile-cancel-turn-availability.spec.ts)
|
||||
python3 scripts/list-docs.py validate
|
||||
python3 scripts/lint-spec-files.py --all
|
||||
git diff --check
|
||||
```
|
||||
|
||||
## Files likely touched
|
||||
|
||||
Existing files:
|
||||
|
||||
- `apps/web/components/quick-chat/quick-chat-content.tsx`
|
||||
- `apps/web/components/command-panel-results.tsx`
|
||||
- `apps/web/components/session-commands.tsx`
|
||||
- `apps/web/components/session-commands.test.tsx`
|
||||
|
||||
New files:
|
||||
|
||||
- `apps/web/components/quick-chat/quick-chat-cancel-commands.tsx`
|
||||
- `apps/web/components/quick-chat/quick-chat-cancel-commands.test.tsx`
|
||||
- `apps/web/e2e/tests/chat/quick-chat-cancel-palette.spec.ts`
|
||||
- `apps/web/e2e/tests/chat/mobile-quick-chat-cancel-palette.spec.ts`
|
||||
|
||||
## Dependencies
|
||||
|
||||
Task 01: composer availability and eligibility wiring.
|
||||
|
||||
## Risks
|
||||
|
||||
Preserve clarification suppression, pending-state session identity, and direct
|
||||
steering delivery. Palette sources do not deduplicate matching command IDs.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`
|
||||
|
||||
## Inputs
|
||||
|
||||
- [Requirements](../../specs/ui/requirements/cancel-turn-progress.md).
|
||||
- [Design](../../specs/ui/system-design/cancel-turn-availability.md).
|
||||
- [Plan and source-trace evidence](plan.md).
|
||||
|
||||
## Results
|
||||
|
||||
- The earlier review correctly identified that the claimed RED integration
|
||||
result was unsupported: the original test only exercised the command builder.
|
||||
The regression is now an actual registry test with both command producers
|
||||
mounted.
|
||||
- GREEN: Quick Chat registers one cancel command only for its active structured
|
||||
conversation, uses that conversation's session ID and existing cancel
|
||||
handler, and guards duplicate dispatch while cancellation is pending. Task
|
||||
cancellation is suppressed for the full Quick Chat open state and restored
|
||||
when it closes. The registry matrix covers A-to-B switching, unmount cleanup,
|
||||
setup/terminal/idle and disconnected clarification states, backend-pending
|
||||
rejection, and retry after a failed request.
|
||||
- The command row uses the existing coarse-pointer sizing convention so the
|
||||
mobile palette action remains touch-sized. Focused registry tests, typecheck,
|
||||
i18n, targeted ESLint, Vite build, and desktop/mobile E2E coverage passed.
|
||||
The mobile palette measured at least 44px and had no horizontal overflow.
|
||||
- Desktop and phone Quick Chat composer regressions negotiate steering, observe
|
||||
generating activity with an empty editor and queue, use the visible cancel
|
||||
control, and wait for the exact session to settle. Desktop also covers
|
||||
detached background work.
|
||||
- Failed Quick Chat cancellation callbacks are logged and clear the optimistic
|
||||
guard so a later command can retry the same session.
|
||||
- Final review cleanup adds an assertion that the underlying task cancel control
|
||||
remains enabled after Quick Chat closes. The desktop Quick Chat suite passes
|
||||
all 3 tests with this assertion.
|
||||
@@ -28,6 +28,17 @@ Cancelling an agent turn can outlive the chat component or browser page that sta
|
||||
- **AC-UI-CANCEL-TURN-PROGRESS-001.7:** Desktop and mobile chat composers expose the same behavior through their shared cancel control.
|
||||
- **AC-UI-CANCEL-TURN-PROGRESS-001.8:** **GIVEN** an agent turn is running, **WHEN** the user activates the cancel control, **THEN** the control is immediately disabled, shows progress, and the backend runs one guarded cancellation.
|
||||
|
||||
|
||||
- **AC-UI-CANCEL-TURN-PROGRESS-001.9:** A starting, generating, or background-working session shall expose cancellation in task and Quick Chat composers, regardless of steering capability, queued messages, or empty input. A fully idle session without a clarification shall not expose cancellation.
|
||||
- **AC-UI-CANCEL-TURN-PROGRESS-001.10:** A connected clarification shall retain its cancel control. A clarification whose agent disconnected shall suppress that control, including when the session still reports running.
|
||||
- **AC-UI-CANCEL-TURN-PROGRESS-001.11:** When Quick Chat is open, its active structured conversation shall own the palette's Cancel turn action. An underlying task or inactive chat shall not receive that action. Closing Quick Chat shall restore the task action when eligible. A terminal, setup tab, or idle conversation shall not expose an underlying task cancellation action.
|
||||
- **AC-UI-CANCEL-TURN-PROGRESS-001.12:** Cancellation visibility shall not change direct-send, steering, or queue behavior. Desktop and phone users shall reach cancellation without entering a message. The phone control shall have a touch target of at least 44 pixels in both dimensions and remain inside the composer.
|
||||
|
||||
## Design and implementation
|
||||
|
||||
- [Cancel control availability design](../system-design/cancel-turn-availability.md)
|
||||
- [Availability fix package](../../../plans/cancel-turn-availability/plan.md)
|
||||
|
||||
## Migrated source detail
|
||||
|
||||
## Why
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
---
|
||||
status: current
|
||||
system: ui
|
||||
created: 2026-09-15
|
||||
requirements:
|
||||
- REQ-UI-CANCEL-TURN-PROGRESS-001
|
||||
---
|
||||
|
||||
# Cancel control availability
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
The existing UI cancellation requirement owns this reusable control contract.
|
||||
Task lifecycle, agent capability negotiation, and backend cancellation remain
|
||||
with their current owners. This design covers control availability and command
|
||||
targeting; the existing cancellation projection is defined by the
|
||||
[backend-owned progress ADR](../../../decisions/2026-08-03-backend-owned-cancellation-progress.md).
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Criteria under REQ-UI-CANCEL-TURN-PROGRESS-001 | Design section |
|
||||
| --- | --- |
|
||||
| AC-UI-CANCEL-TURN-PROGRESS-001.1 through .8 | Existing progress ADR; Shared control |
|
||||
| AC-UI-CANCEL-TURN-PROGRESS-001.9, .10, .12 | Shared control |
|
||||
| AC-UI-CANCEL-TURN-PROGRESS-001.11 | Palette ownership |
|
||||
|
||||
## Shared control
|
||||
|
||||
`useSessionState` already distinguishes `isWorking` from `isAgentBusy`.
|
||||
The latter describes queue admission; it is false during direct steering and
|
||||
background work. `useComposerProps` passes `panelState.isWorking` as a required
|
||||
`ChatInputContainer` prop. `shouldShowCancelAgent` consumes that signal and
|
||||
retains its connected/disconnected clarification override.
|
||||
|
||||
Preserve STARTING eligibility, already offered by the queue-based control.
|
||||
Use the hook's preparation-inclusive working signal so status and cancel agree
|
||||
while an identified session prepares its executor. A missing session ID cannot
|
||||
produce an actionable cancel control. Moving a task alone is not working.
|
||||
|
||||
`SubmitButton` receives explicit `canCancelAgent` through the existing body and
|
||||
both toolbar presentations. Preserve `isAgentBusy` for send visibility, input
|
||||
copy, queue handling, and styling. Keep pending cancellation disabled and animated
|
||||
through the existing optimistic and backend session-keyed state. Keep the
|
||||
existing `agent.cancel` handler, payload, authorization, and retry behavior.
|
||||
Use the existing localized cancel label as the icon button's accessible name.
|
||||
|
||||
The passthrough composer keeps Escape and its parent `onCancel` callback as
|
||||
editor dismissal. It does not own an agent-cancel transport, so it explicitly
|
||||
suppresses the agent cancel control even while its session is working. This
|
||||
prevents a dismissal callback from being mistaken for cancellation.
|
||||
|
||||
Phone composition reuses `chat-input-toolbar-mobile.tsx` and its existing
|
||||
44px controls. Desktop retains the 28px controls. No new overlay or scroll owner
|
||||
is introduced: messages scroll above the composer, whose current viewport,
|
||||
keyboard, and safe-area handling remain authoritative.
|
||||
|
||||
## Palette ownership
|
||||
|
||||
Reuse `buildSessionCommands` for the localized cancel entry; do not mount the
|
||||
full task-oriented `SessionCommands` in Quick Chat. That component also creates
|
||||
worktree, panel, archive, and subtask actions that do not belong to this surface.
|
||||
|
||||
A small Quick Chat command component uses its explicit session ID, working state,
|
||||
clarification state, and existing `handleCancelTurn`. Register only when
|
||||
`quickChat.isOpen`, `activeKind === "conversation"`, and `activeSessionId` matches
|
||||
that structured conversation. Require the same cancel eligibility as the composer.
|
||||
Guard dispatch against cancellation already pending for that session.
|
||||
|
||||
While Quick Chat is open, suppress only the underlying task's `session-cancel`
|
||||
entry in `SessionCommands`. Restore it on close. Suppression also applies when
|
||||
Quick Chat shows setup, terminal, or idle content. Do not change unrelated task
|
||||
commands. `useRegisterCommands` removes registrations when the source unmounts
|
||||
or its inputs change. Tab switching must replace the callback and eligibility.
|
||||
|
||||
The registry concatenates sources without deduplication. Priority cannot enforce
|
||||
ownership; tests must assert exactly one eligible cancellation entry, or zero
|
||||
when the foreground surface is ineligible. Never obtain the Quick Chat target
|
||||
from `tasks.activeSessionId`.
|
||||
|
||||
## Compatibility and verification
|
||||
|
||||
No protocol, persistence, runtime flag, or cancellation timeout changes.
|
||||
Steering and background promptability retain their existing contracts.
|
||||
Tests distinguish working state from queue state and verify the full prop path.
|
||||
The queue E2E helper shall wait on the existing queue-derived composer classes
|
||||
(`chat-input-running` or `chat-input-running-plan`), scoped to the active composer,
|
||||
instead of treating cancellation visibility as evidence of queue admission.
|
||||
|
||||
Use the [fix package](../../../plans/cancel-turn-availability/plan.md) for exact
|
||||
unit, browser, and mobile verification. This design adds no backend telemetry.
|
||||
Reference in New Issue
Block a user