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);
|
||||
|
||||
Reference in New Issue
Block a user