fix(web): keep Create Task open on Escape (#2803)
This commit is contained in:
@@ -26,6 +26,7 @@ vi.mock("@/components/task/chat/file-attachment", async () => {
|
||||
// Captures the slot props TaskFormInputs hands its plugin composer action,
|
||||
// which is the only way text now reaches the description programmatically.
|
||||
const pluginSlotCalls: PluginComposerSlotProps[] = [];
|
||||
const mentionMocks = vi.hoisted(() => ({ handleKeyDown: vi.fn() }));
|
||||
|
||||
vi.mock("@/components/plugins/plugin-slot", () => ({
|
||||
PluginSlot: ({ slotProps }: { slotProps: PluginComposerSlotProps }) => {
|
||||
@@ -45,7 +46,7 @@ vi.mock("@/hooks/use-task-create-prompt-mention", () => ({
|
||||
query: "",
|
||||
selectedIndex: 0,
|
||||
handleChange: (_: string) => {},
|
||||
handleKeyDown: (_: React.KeyboardEvent) => {},
|
||||
handleKeyDown: mentionMocks.handleKeyDown,
|
||||
handleSelect: () => {},
|
||||
closeMenu: () => {},
|
||||
setSelectedIndex: () => {},
|
||||
@@ -55,6 +56,7 @@ vi.mock("@/hooks/use-task-create-prompt-mention", () => ({
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
pluginSlotCalls.length = 0;
|
||||
mentionMocks.handleKeyDown.mockReset();
|
||||
vi.restoreAllMocks();
|
||||
vi.mocked(processFile).mockReset();
|
||||
});
|
||||
@@ -90,6 +92,19 @@ function renderTaskFormInputs(initial: string, strict = false) {
|
||||
return { ...utils, textarea, ref };
|
||||
}
|
||||
|
||||
describe("TaskFormInputs mention keyboard routing", () => {
|
||||
it("handles Escape in capture before a target listener stops the bubble phase", () => {
|
||||
const { textarea } = renderTaskFormInputs("");
|
||||
const stopAtTarget = (event: KeyboardEvent) => event.stopPropagation();
|
||||
textarea.addEventListener("keydown", stopAtTarget);
|
||||
|
||||
fireEvent.keyDown(textarea, { key: "Escape", bubbles: true, cancelable: true });
|
||||
|
||||
textarea.removeEventListener("keydown", stopAtTarget);
|
||||
expect(mentionMocks.handleKeyDown).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskFormInputs plugin composer action — rendering", () => {
|
||||
it("renders the composer slot in task-create mode", () => {
|
||||
renderTaskFormInputs("");
|
||||
|
||||
@@ -829,15 +829,18 @@ function useTextareaHandlers(
|
||||
mentionHandleChange(e.target.value, e.target.selectionStart),
|
||||
[mentionHandleChange],
|
||||
);
|
||||
const handleKeyDownCapture = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => mentionHandleKeyDown(e),
|
||||
[mentionHandleKeyDown],
|
||||
);
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
mentionHandleKeyDown(e);
|
||||
if (e.defaultPrevented) return;
|
||||
onKeyDown?.(e);
|
||||
},
|
||||
[mentionHandleKeyDown, onKeyDown],
|
||||
[onKeyDown],
|
||||
);
|
||||
return { handleChange, handleKeyDown };
|
||||
return { handleChange, handleKeyDownCapture, handleKeyDown };
|
||||
}
|
||||
|
||||
function useFileInputClick(addFiles: (files: File[]) => Promise<void> | void) {
|
||||
@@ -935,7 +938,10 @@ export const TaskFormInputs = memo(function TaskFormInputs({
|
||||
value: description,
|
||||
onChange: setDescriptionValue,
|
||||
});
|
||||
const { handleChange, handleKeyDown } = useTextareaHandlers(mention, onKeyDown);
|
||||
const { handleChange, handleKeyDownCapture, handleKeyDown } = useTextareaHandlers(
|
||||
mention,
|
||||
onKeyDown,
|
||||
);
|
||||
const { fileInputRef, handleAttachClick, handleFileInputChange } = useFileInputClick(addFiles);
|
||||
const pluginActions = useCreationComposerPluginActions({
|
||||
isSessionMode,
|
||||
@@ -969,6 +975,7 @@ export const TaskFormInputs = memo(function TaskFormInputs({
|
||||
}
|
||||
value={description}
|
||||
onChange={handleChange}
|
||||
onKeyDownCapture={handleKeyDownCapture}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
data-testid="task-description-input"
|
||||
|
||||
@@ -14,12 +14,24 @@ const USER_EDIT = "User edit";
|
||||
const PROMPT_RESULT_RECOVERY_TEST_ID = "prompt-result-recovery";
|
||||
const ENHANCE_PROMPT_BUTTON_TEST_ID = "enhance-prompt-button";
|
||||
|
||||
type EscapeEvent = { preventDefault: () => void };
|
||||
|
||||
let allowProgrammaticSet = true;
|
||||
let mockFs: DialogFormState;
|
||||
let dialogEscapeHandler: ((event: EscapeEvent) => void) | undefined;
|
||||
|
||||
vi.mock("@kandev/ui/dialog", () => ({
|
||||
Dialog: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogContent: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogContent: ({
|
||||
children,
|
||||
onEscapeKeyDown,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onEscapeKeyDown?: (event: EscapeEvent) => void;
|
||||
}) => {
|
||||
dialogEscapeHandler = onEscapeKeyDown;
|
||||
return <div>{children}</div>;
|
||||
},
|
||||
DialogHeader: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
DialogFooter: ({ children }: { children: ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
@@ -324,10 +336,11 @@ function buildMockFs(initialDescription = ORIGINAL_PROMPT): DialogFormState {
|
||||
};
|
||||
}
|
||||
|
||||
function renderDialog() {
|
||||
function renderDialog(mode: "create" | "edit" | "session" = "create") {
|
||||
return render(
|
||||
<TaskCreateDialog
|
||||
open
|
||||
mode={mode}
|
||||
onOpenChange={() => undefined}
|
||||
workspaceId="workspace-1"
|
||||
workflowId={null}
|
||||
@@ -346,9 +359,32 @@ beforeEach(() => {
|
||||
enhancePromptMock.mockReset();
|
||||
toastMock.mockReset();
|
||||
setHasDescriptionMock.mockReset();
|
||||
dialogEscapeHandler = undefined;
|
||||
mockFs = buildMockFs();
|
||||
});
|
||||
|
||||
describe("TaskCreateDialog Escape dismissal", () => {
|
||||
it("prevents Escape from dismissing create mode", () => {
|
||||
renderDialog();
|
||||
const event = { preventDefault: vi.fn() };
|
||||
|
||||
expect(dialogEscapeHandler).toBeTypeOf("function");
|
||||
dialogEscapeHandler?.(event);
|
||||
|
||||
expect(event.preventDefault).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.each(["edit", "session"] as const)("keeps Escape dismissal available in %s mode", (mode) => {
|
||||
renderDialog(mode);
|
||||
const event = { preventDefault: vi.fn() };
|
||||
|
||||
expect(dialogEscapeHandler).toBeTypeOf("function");
|
||||
dialogEscapeHandler?.(event);
|
||||
|
||||
expect(event.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskCreateDialog prompt enhancement", () => {
|
||||
it("applies the enhanced prompt immediately when the description is unchanged", async () => {
|
||||
let deliver: ((result: { content: string }) => boolean | Promise<boolean>) | undefined;
|
||||
|
||||
@@ -253,6 +253,9 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) {
|
||||
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
|
||||
<DialogContent
|
||||
ref={setPopoverContainer}
|
||||
onEscapeKeyDown={(event) => {
|
||||
if (setup.isCreateMode) event.preventDefault();
|
||||
}}
|
||||
data-testid="create-task-dialog"
|
||||
data-webkit-safe-motion="true"
|
||||
showCloseButton={false}
|
||||
|
||||
@@ -241,6 +241,12 @@ test.describe("Entity reference composer", () => {
|
||||
await expect(testPage.getByRole("option").filter({ hasText: targetTitle })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
|
||||
await editor.press("Escape");
|
||||
await expect(testPage.getByRole("option").filter({ hasText: targetTitle })).toHaveCount(0);
|
||||
await expect(editor).toBeFocused();
|
||||
await editor.pressSequentially(" continued");
|
||||
await expect(editor).toHaveText(new RegExp(`@${targetTitle} continued`));
|
||||
});
|
||||
|
||||
test("task chat restores a keyboard-selected draft and explicitly sends durable metadata", async ({
|
||||
@@ -490,6 +496,7 @@ test.describe("Entity reference composer", () => {
|
||||
await editor.pressSequentially("E");
|
||||
await editor.press("Escape");
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(editor).toBeFocused();
|
||||
await editor.pressSequentially(" continued");
|
||||
await expect(menu).toHaveCount(0);
|
||||
await expect(editor).toHaveText(
|
||||
|
||||
@@ -136,7 +136,10 @@ test.describe("Improve Kandev dialog", () => {
|
||||
await contribute.click();
|
||||
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await testPage.keyboard.press("Escape");
|
||||
await testPage
|
||||
.getByTestId("create-task-dialog")
|
||||
.getByRole("button", { name: "Cancel", exact: true })
|
||||
.click();
|
||||
await expect(testPage.getByTestId("create-task-dialog")).toBeHidden();
|
||||
await testPage.getByTestId("sidebar-improve-kandev-button").click();
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ test.describe("Sidebar office gating", () => {
|
||||
await newTask.click();
|
||||
await expect(kanbanDialog).toBeVisible();
|
||||
await expect(officeDialog).toHaveCount(0);
|
||||
await testPage.keyboard.press("Escape");
|
||||
await kanbanDialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(kanbanDialog).toHaveCount(0);
|
||||
|
||||
// Office workspace active: the Office "New issue" dialog — and on a shared
|
||||
|
||||
@@ -222,7 +222,7 @@ test.describe("Task creation", () => {
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByTestId("workflow-selector-trigger")).toContainText("Dev");
|
||||
await testPage.keyboard.press("Escape");
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
await testPage.getByTestId("sidebar-workspace-trigger").click();
|
||||
@@ -242,7 +242,7 @@ test.describe("Task creation", () => {
|
||||
await kanban.createTaskButton.first().click();
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect(dialog.getByTestId("workflow-selector-trigger")).toContainText("Support");
|
||||
await testPage.keyboard.press("Escape");
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
// Reload workspace A again to prove the two memories do not overwrite
|
||||
|
||||
@@ -174,7 +174,7 @@ test.describe("Enhance prompt button in task creation", () => {
|
||||
await textarea.fill(prompt);
|
||||
await enhanceBtn.click();
|
||||
await requestGate;
|
||||
await testPage.keyboard.press("Escape");
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(dialog).not.toBeVisible();
|
||||
|
||||
await openCreateTaskDialog(testPage, seedData.workspaceId);
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, test } from "../../fixtures/test-base";
|
||||
import { useRegularMode } from "../../helpers/regular-mode";
|
||||
import { MobileKanbanPage } from "../../pages/mobile-kanban-page";
|
||||
|
||||
useRegularMode();
|
||||
|
||||
test("keeps Create Task open after hardware-keyboard Escape on mobile", async ({
|
||||
testPage,
|
||||
prCapture,
|
||||
}) => {
|
||||
const mobile = new MobileKanbanPage(testPage);
|
||||
await mobile.goto();
|
||||
await mobile.mobileFab.tap();
|
||||
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
const textarea = dialog.getByTestId("task-description-input");
|
||||
await expect(dialog).toHaveAttribute("data-state", "open");
|
||||
await textarea.fill("Keep this mobile draft");
|
||||
|
||||
await textarea.press("Escape");
|
||||
|
||||
await expect(dialog).toHaveAttribute("data-state", "open");
|
||||
await expect(textarea).toHaveValue("Keep this mobile draft");
|
||||
await expect(textarea).toBeFocused();
|
||||
await prCapture.screenshot("create-task-dialog-after-escape-mobile", {
|
||||
caption: "Create Task stays open with the draft after Escape on mobile.",
|
||||
});
|
||||
});
|
||||
|
||||
test("closes prompt autocomplete and keeps focus after hardware-keyboard Escape on mobile", async ({
|
||||
testPage,
|
||||
}) => {
|
||||
const mobile = new MobileKanbanPage(testPage);
|
||||
await mobile.goto();
|
||||
await mobile.mobileFab.tap();
|
||||
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
const textarea = dialog.getByTestId("task-description-input");
|
||||
const menuTitle = testPage.getByText(/Mention tasks, files, prompts/i);
|
||||
await textarea.pressSequentially("@");
|
||||
await expect(menuTitle).toBeVisible();
|
||||
|
||||
await testPage.keyboard.press("Escape");
|
||||
|
||||
await expect(menuTitle).toHaveCount(0);
|
||||
await expect(dialog).toHaveAttribute("data-state", "open");
|
||||
await expect(textarea).toBeFocused();
|
||||
await textarea.pressSequentially("continued");
|
||||
await expect(textarea).toHaveValue("@continued");
|
||||
});
|
||||
@@ -77,8 +77,38 @@ test.describe("@-mention autocomplete: adversarial QA", () => {
|
||||
// The @query text is preserved (Esc just closes the menu, doesn't undo typing).
|
||||
await expect(textarea).toHaveValue("@qa-es");
|
||||
|
||||
// Dialog should still be open — Escape went to the menu, not the dialog.
|
||||
await expect(testPage.getByTestId("create-task-dialog")).toBeVisible();
|
||||
// The open state must persist after the close animation window.
|
||||
await expect(testPage.getByTestId("create-task-dialog")).toHaveAttribute("data-state", "open");
|
||||
await expect(textarea).toBeFocused();
|
||||
|
||||
await textarea.pressSequentially(" continued");
|
||||
await expect(textarea).toHaveValue("@qa-es continued");
|
||||
await expect(testPage.getByText(MENU_TITLE)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Escape keeps Create Task open without an autocomplete menu", async ({
|
||||
testPage,
|
||||
prCapture,
|
||||
}) => {
|
||||
test.setTimeout(60_000);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
await kanban.createTaskButton.first().click();
|
||||
|
||||
const dialog = testPage.getByTestId("create-task-dialog");
|
||||
const textarea = testPage.getByTestId("task-description-input");
|
||||
await expect(dialog).toHaveAttribute("data-state", "open");
|
||||
await textarea.fill("Keep this draft");
|
||||
await expect(testPage.getByText(MENU_TITLE)).toHaveCount(0);
|
||||
|
||||
await textarea.press("Escape");
|
||||
|
||||
await expect(dialog).toHaveAttribute("data-state", "open");
|
||||
await expect(textarea).toHaveValue("Keep this draft");
|
||||
await prCapture.screenshot("create-task-dialog-after-escape-desktop", {
|
||||
caption: "Create Task stays open with the draft after Escape on desktop.",
|
||||
});
|
||||
});
|
||||
|
||||
test("ArrowDown + Enter selects the second prompt", async ({ testPage, apiClient }) => {
|
||||
|
||||
@@ -258,8 +258,8 @@ test.describe("Workflow agent profile", () => {
|
||||
await expect(testPage.getByText("Agent set by workflow")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(startButton).toBeEnabled({ timeout: 10_000 });
|
||||
|
||||
// Cancel (Escape) and re-open.
|
||||
await testPage.keyboard.press("Escape");
|
||||
// Cancel and re-open.
|
||||
await dialog.getByRole("button", { name: "Cancel", exact: true }).click();
|
||||
await expect(dialog).toBeHidden({ timeout: 10_000 });
|
||||
|
||||
await kanban.createTaskButton.first().click();
|
||||
|
||||
@@ -113,6 +113,53 @@ describe("useInlineMention scheduling", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("useInlineMention Escape", () => {
|
||||
it("claims the key and restores input focus after closing the menu", () => {
|
||||
const frames: FrameRequestCallback[] = [];
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
frames.push(callback);
|
||||
return frames.length;
|
||||
});
|
||||
vi.stubGlobal("cancelAnimationFrame", vi.fn());
|
||||
|
||||
const inputState = { value: "@qa", caretPos: 3 };
|
||||
const input = makeMutableInput(inputState);
|
||||
const inputRef = { current: input };
|
||||
const { result } = renderHook(() =>
|
||||
useInlineMention({
|
||||
inputRef,
|
||||
value: inputState.value,
|
||||
onChange: (nextValue) => {
|
||||
inputState.value = nextValue;
|
||||
},
|
||||
promptInsertMode: "inline",
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => result.current.handleChange("@qa", 3));
|
||||
act(() => frames.shift()?.(0));
|
||||
expect(result.current.isOpen).toBe(true);
|
||||
|
||||
const preventDefault = vi.fn();
|
||||
const stopPropagation = vi.fn();
|
||||
act(() => {
|
||||
result.current.handleKeyDown({
|
||||
key: "Escape",
|
||||
preventDefault,
|
||||
stopPropagation,
|
||||
} as unknown as React.KeyboardEvent);
|
||||
});
|
||||
|
||||
expect(preventDefault).toHaveBeenCalledOnce();
|
||||
expect(stopPropagation).toHaveBeenCalledOnce();
|
||||
expect(result.current.isOpen).toBe(false);
|
||||
expect(frames).toHaveLength(1);
|
||||
|
||||
act(() => frames.shift()?.(0));
|
||||
expect(input.focus).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
describe("makePromptItem — context mode (default chat behavior)", () => {
|
||||
it("deletes the @query text and calls onPromptSelect", () => {
|
||||
const prompt = { id: "p1", name: "bug-template", content: "Reproduce, isolate, fix." };
|
||||
|
||||
@@ -277,6 +277,7 @@ type MentionKeyboardParams = {
|
||||
setSelectedIndex: (v: number | ((prev: number) => number)) => void;
|
||||
handleSelect: (item: MentionItem) => void;
|
||||
closeMenu: () => void;
|
||||
restoreFocus: () => void;
|
||||
};
|
||||
|
||||
function useMentionKeyboard({
|
||||
@@ -286,6 +287,7 @@ function useMentionKeyboard({
|
||||
setSelectedIndex,
|
||||
handleSelect,
|
||||
closeMenu,
|
||||
restoreFocus,
|
||||
}: MentionKeyboardParams) {
|
||||
return useCallback(
|
||||
(event: React.KeyboardEvent) => {
|
||||
@@ -309,11 +311,13 @@ function useMentionKeyboard({
|
||||
break;
|
||||
case "Escape":
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
closeMenu();
|
||||
restoreFocus();
|
||||
break;
|
||||
}
|
||||
},
|
||||
[isOpen, filteredItems, selectedIndex, setSelectedIndex, handleSelect, closeMenu],
|
||||
[isOpen, filteredItems, selectedIndex, setSelectedIndex, handleSelect, closeMenu, restoreFocus],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -433,6 +437,10 @@ export function useInlineMention({
|
||||
clearMentionState(setIsOpen, setTriggerStart, setQuery);
|
||||
}, [invalidateMentionChange]);
|
||||
|
||||
const restoreFocus = useCallback(() => {
|
||||
requestAnimationFrame(() => inputRef.current?.focus());
|
||||
}, [inputRef]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(item: MentionItem) => {
|
||||
const input = inputRef.current;
|
||||
@@ -450,6 +458,7 @@ export function useInlineMention({
|
||||
setSelectedIndex,
|
||||
handleSelect,
|
||||
closeMenu,
|
||||
restoreFocus,
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
---
|
||||
spec: docs/specs/tasks/task-create-escape-dismissal.md
|
||||
created: 2026-08-19
|
||||
status: complete
|
||||
---
|
||||
|
||||
# Implementation Plan: Create Task Escape Dismissal
|
||||
|
||||
## Overview
|
||||
|
||||
The Create Task dialog uses the default Escape dismissal from Radix Dialog.
|
||||
Its autocomplete handler receives the event after Radix requests dialog closure.
|
||||
The first fix blocked Radix dismissal only in create mode and relied on the event reaching the
|
||||
autocomplete's later bubble handler. The follow-up makes autocomplete dismissal and focus retention
|
||||
capture-safe across browser runtimes.
|
||||
|
||||
## Confirmed root cause
|
||||
|
||||
- PR #1155 added prompt autocomplete to the task-create prompt field.
|
||||
- `TaskCreateDialog` does not pass `onEscapeKeyDown` to `DialogContent`.
|
||||
- Radix handles Escape on the document capture phase and requests closure first.
|
||||
- `useInlineMention` prevents the same event later in the input event path.
|
||||
- The current E2E assertion checks visibility before the 100 ms close animation completes.
|
||||
This timing lets a closing dialog satisfy the assertion.
|
||||
- The first repair added a create-mode Radix guard but left autocomplete handling in the textarea's
|
||||
React bubble handler. The PR preview can therefore keep the dialog open while a browser/runtime
|
||||
moves focus before that later handler closes the menu.
|
||||
- Nested Escape behavior must be claimed in capture phase with both `preventDefault()` and
|
||||
`stopPropagation()`, then explicitly restore prompt focus after the event cycle.
|
||||
|
||||
The smallest reproduction opens Create Task and presses Escape in the prompt field.
|
||||
The dialog enters `data-state="closed"` with or without an open autocomplete menu.
|
||||
|
||||
## Frontend
|
||||
|
||||
### Create Task dialog
|
||||
|
||||
Update `apps/web/components/task-create-dialog.tsx`.
|
||||
Pass an `onEscapeKeyDown` handler to `DialogContent`.
|
||||
Call `preventDefault()` only when `setup.isCreateMode` is true.
|
||||
Do not stop propagation because the nested autocomplete handler must receive Escape.
|
||||
|
||||
Keep Edit Task and New Agent behavior unchanged.
|
||||
Do not change the shared `@kandev/ui` dialog component.
|
||||
|
||||
### Prompt autocomplete
|
||||
|
||||
Update `apps/web/components/task-create-dialog-selectors.tsx` and
|
||||
`apps/web/hooks/use-inline-mention.ts`.
|
||||
Run the mention keyboard handler from the prompt textarea's capture phase while leaving the normal
|
||||
form shortcut handler in bubble phase. When Escape closes an open mention menu, claim the event and
|
||||
restore the prompt textarea's focus on the next animation frame.
|
||||
|
||||
## Tests
|
||||
|
||||
- **What:** Create mode prevents Radix Escape dismissal, while edit mode does not add the create-mode guard.
|
||||
**File:** `apps/web/components/task-create-dialog.test.tsx`.
|
||||
**How:** expose the mocked `DialogContent` Escape callback and call it with a `preventDefault` spy.
|
||||
- **What:** Mention Escape runs even when a target listener prevents the later bubble phase.
|
||||
**File:** `apps/web/components/task-create-dialog-selectors.test.tsx`.
|
||||
**How:** stop propagation at the textarea target and assert the mention handler already ran in capture.
|
||||
- **What:** Mention Escape claims the event and restores input focus.
|
||||
**File:** `apps/web/hooks/use-inline-mention.test.ts`.
|
||||
**How:** open a mention menu, press Escape, and flush the scheduled focus restoration.
|
||||
|
||||
## E2E Tests
|
||||
|
||||
- **Scenario:** An open `@` autocomplete menu closes on Escape while Create Task remains open.
|
||||
**File:** `apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts`.
|
||||
**What to verify:** the menu disappears, the query remains, the dialog keeps `data-state="open"`,
|
||||
the textarea stays focused, and typing continues.
|
||||
- **Scenario:** Create Task remains open when no autocomplete menu is visible.
|
||||
**File:** `apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts`.
|
||||
**What to verify:** Escape keeps `data-state="open"` and preserves the prompt value.
|
||||
- **Scenario:** A phone user with a hardware keyboard presses Escape without an open menu.
|
||||
**File:** `apps/web/e2e/tests/task/mobile-task-create-escape.spec.ts`.
|
||||
**What to verify:** the full-height dialog keeps `data-state="open"` in the `mobile-chrome` project.
|
||||
- **Scenario:** A phone user with a hardware keyboard closes an open `@` menu.
|
||||
**File:** `apps/web/e2e/tests/task/mobile-task-create-escape.spec.ts`.
|
||||
**What to verify:** the menu closes and prompt focus remains in the shared mobile dialog.
|
||||
- **Scenario:** Task chat closes open `@` and `#` menus without losing editor focus.
|
||||
**File:** `apps/web/e2e/tests/chat/entity-reference-composer.spec.ts`.
|
||||
**What to verify:** typing continues in the same editor after Escape.
|
||||
|
||||
## Mobile parity
|
||||
|
||||
The existing mobile entry point is the Kanban task-creation action.
|
||||
The nearest shipped exemplar is `mobile-create-task-webkit-rendering.spec.ts`.
|
||||
The same `TaskCreateDialog` component owns desktop and mobile Escape behavior.
|
||||
|
||||
This fix does not change layout, touch targets, scroll ownership, safe areas, or the primary action.
|
||||
The new mobile E2E scenario covers a hardware keyboard without changing the phone composition.
|
||||
|
||||
## Verification Results
|
||||
|
||||
- RED component test: 3 expected failures because `DialogContent` had no Escape handler.
|
||||
- RED desktop E2E: 2 expected failures because the dialog changed to `data-state="closed"`.
|
||||
- GREEN component test: 7 tests passed.
|
||||
- GREEN desktop E2E: 2 Escape scenarios passed.
|
||||
- GREEN mobile E2E: 1 hardware-keyboard Escape scenario passed.
|
||||
- Frontend typecheck passed.
|
||||
- Frontend lint passed.
|
||||
- Follow-up RED unit run: 2 expected failures and 32 passing tests. The mention handler did not
|
||||
receive target-stopped Escape during capture, and Escape did not stop propagation or restore focus.
|
||||
- Follow-up GREEN unit run: 34 tests passed across the inline mention hook and Task Form Inputs.
|
||||
- Follow-up desktop E2E: 1 Create Task autocomplete focus and continued-typing scenario passed.
|
||||
- Follow-up mobile E2E: 2 hardware-keyboard Escape scenarios passed, including autocomplete focus.
|
||||
- Follow-up task-chat E2E: 2 `@` and `#` dismissal focus and continued-typing scenarios passed.
|
||||
- Follow-up frontend typecheck and changed-file ESLint passed.
|
||||
|
||||
## Implementation Waves And Parallel Candidates
|
||||
|
||||
Wave 1 (sequential):
|
||||
|
||||
- [x] [task-01-guard-dialog-escape](task-01-guard-dialog-escape.md)
|
||||
|
||||
Wave 2 (sequential):
|
||||
|
||||
- [x] [task-02-capture-autocomplete-escape](task-02-capture-autocomplete-escape.md)
|
||||
|
||||
This plan does not authorize subagents.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None.
|
||||
@@ -0,0 +1,112 @@
|
||||
---
|
||||
id: "01-guard-dialog-escape"
|
||||
title: "Guard Create Task Escape dismissal"
|
||||
status: complete
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/task-create-escape-dismissal.md"
|
||||
---
|
||||
|
||||
# Task 01: Guard Create Task Escape Dismissal
|
||||
|
||||
## Intent
|
||||
|
||||
Keep the Create Task dialog open for every Escape press.
|
||||
Keep nested autocomplete dismissal and non-create dialog behavior unchanged.
|
||||
|
||||
## Inputs
|
||||
|
||||
- `docs/specs/tasks/task-create-escape-dismissal.md`
|
||||
- `docs/plans/task-create-escape-dismissal/plan.md`
|
||||
- `apps/web/components/task-create-dialog.tsx`
|
||||
- `apps/web/components/task-create-dialog.test.tsx`
|
||||
- `apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts`
|
||||
- `apps/web/e2e/tests/task/mobile-create-task-webkit-rendering.spec.ts`
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Escape never closes `TaskCreateDialog` in create mode.
|
||||
- Escape closes an open prompt autocomplete menu and preserves the draft.
|
||||
- Edit Task and New Agent keep their current Escape behavior.
|
||||
- Desktop and mobile E2E tests prove the create-mode contract.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Add the component and E2E regression assertions.
|
||||
2. Run the focused tests and record the expected failures.
|
||||
3. Add the create-mode `onEscapeKeyDown` guard.
|
||||
4. Run the same tests and record their successful results.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/task-create-dialog.tsx`
|
||||
- `apps/web/components/task-create-dialog.test.tsx`
|
||||
- `apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts`
|
||||
- `apps/web/e2e/tests/task/mobile-task-create-escape.spec.ts`
|
||||
- `docs/plans/task-create-escape-dismissal/plan.md`
|
||||
- `docs/plans/task-create-escape-dismissal/task-01-guard-dialog-escape.md`
|
||||
|
||||
## Dependencies
|
||||
|
||||
None.
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`. The test and production changes share the dialog contract.
|
||||
|
||||
## Verification
|
||||
|
||||
If workspace dependencies are absent, run this bootstrap once:
|
||||
|
||||
```bash
|
||||
cd apps && pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
Run the component test:
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- --run components/task-create-dialog.test.tsx
|
||||
```
|
||||
|
||||
Run the desktop E2E scenarios with a fresh production build:
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm e2e:run tests/task/task-create-prompt-autocomplete-qa.spec.ts -- --grep "Escape"
|
||||
```
|
||||
|
||||
Run the mobile E2E scenario with a fresh production build:
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm e2e:run --project mobile-chrome tests/task/mobile-task-create-escape.spec.ts
|
||||
```
|
||||
|
||||
Run the frontend type and lint checks:
|
||||
|
||||
```bash
|
||||
cd apps/web && pnpm run typecheck
|
||||
cd apps && pnpm --filter @kandev/web lint
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
- An unconditional shared-dialog guard can change Edit Task and New Agent behavior.
|
||||
- Stopping propagation can keep the autocomplete menu open.
|
||||
- A visibility-only E2E assertion can pass during the close animation.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report the changed files, RED and GREEN results, blockers, and remaining risks.
|
||||
Update this task status and the plan checkbox in the same conversation.
|
||||
|
||||
## Results
|
||||
|
||||
- RED: `task-create-dialog.test.tsx` failed 3 Escape contract tests as expected.
|
||||
- RED: the focused desktop E2E run failed both Escape scenarios with
|
||||
`data-state="closed"`.
|
||||
- GREEN: `task-create-dialog.test.tsx` passed 7 tests.
|
||||
- GREEN: the focused desktop E2E run passed 2 tests.
|
||||
- GREEN: `mobile-task-create-escape.spec.ts` passed 1 test in `mobile-chrome`.
|
||||
- GREEN: frontend typecheck passed.
|
||||
- GREEN: frontend lint passed.
|
||||
- Fresh desktop and mobile PR screenshots were captured with synthetic E2E data.
|
||||
@@ -0,0 +1,82 @@
|
||||
---
|
||||
id: "02-capture-autocomplete-escape"
|
||||
title: "Capture autocomplete Escape and retain focus"
|
||||
status: complete
|
||||
wave: 2
|
||||
depends_on: ["01-guard-dialog-escape"]
|
||||
plan: "plan.md"
|
||||
spec: "../../specs/tasks/task-create-escape-dismissal.md"
|
||||
---
|
||||
|
||||
# Task 02: Capture Autocomplete Escape And Retain Focus
|
||||
|
||||
## Intent
|
||||
|
||||
Close the Create Task prompt autocomplete before later browser or dialog handlers can move focus.
|
||||
Keep the prompt textarea focused and preserve the equivalent `@` and `#` task-chat behavior.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Create Task handles an open mention menu's Escape during capture phase.
|
||||
- The inner autocomplete claims Escape with `preventDefault()` and `stopPropagation()`.
|
||||
- The menu closes, the dialog remains open, and the prompt textarea regains focus on the next frame.
|
||||
- Task chat keeps editor focus after dismissing `@` and `#` autocomplete.
|
||||
- Desktop and mobile hardware-keyboard scenarios prove continued typing after dismissal.
|
||||
|
||||
## TDD sequence
|
||||
|
||||
1. Add unit regressions for capture-phase delivery and scheduled focus restoration.
|
||||
2. Run them and record the expected failures on the current implementation.
|
||||
3. Move mention keyboard handling to textarea capture and add focus restoration.
|
||||
4. Add desktop, mobile, and task-chat E2E focus/continued-typing assertions.
|
||||
5. Run the focused unit and E2E commands with retries disabled.
|
||||
|
||||
## Files likely touched
|
||||
|
||||
- `apps/web/components/task-create-dialog-selectors.tsx`
|
||||
- `apps/web/components/task-create-dialog-selectors.test.tsx`
|
||||
- `apps/web/hooks/use-inline-mention.ts`
|
||||
- `apps/web/hooks/use-inline-mention.test.ts`
|
||||
- `apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts`
|
||||
- `apps/web/e2e/tests/task/mobile-task-create-escape.spec.ts`
|
||||
- `apps/web/e2e/tests/chat/entity-reference-composer.spec.ts`
|
||||
|
||||
## Parallelism
|
||||
|
||||
`sequential`. Unit and E2E coverage exercise the same keyboard event contract.
|
||||
|
||||
## Verification
|
||||
|
||||
```bash
|
||||
cd apps && pnpm --filter @kandev/web test -- --run \
|
||||
hooks/use-inline-mention.test.ts \
|
||||
components/task-create-dialog-selectors.test.tsx
|
||||
cd apps/web && pnpm e2e:run tests/task/task-create-prompt-autocomplete-qa.spec.ts \
|
||||
-- --grep "Escape closes the menu" --retries=0
|
||||
cd apps/web && pnpm e2e:run --no-build --project mobile-chrome \
|
||||
tests/task/mobile-task-create-escape.spec.ts -- --retries=0
|
||||
cd apps/web && pnpm e2e:run --no-build tests/chat/entity-reference-composer.spec.ts \
|
||||
-- --grep "keeps Kandev task suggestions under @|pasted hash text stays literal" --retries=0
|
||||
cd apps/web && pnpm run typecheck
|
||||
```
|
||||
|
||||
## Risks
|
||||
|
||||
- Stopping Escape too early could prevent the inner autocomplete from receiving it.
|
||||
- Moving all textarea keyboard handling to capture could suppress form shortcuts; only mention
|
||||
handling moves, while unclaimed keys continue to the existing bubble handler.
|
||||
- A focus-only assertion can miss a menu that remained mounted; E2E must assert both menu removal
|
||||
and continued typing.
|
||||
|
||||
## Results
|
||||
|
||||
- RED unit run: 2 expected failures and 32 passing tests.
|
||||
- GREEN unit run: 34 tests passed.
|
||||
- Desktop Create Task E2E: 1 autocomplete Escape scenario passed.
|
||||
- Mobile Create Task E2E: 2 hardware-keyboard Escape scenarios passed.
|
||||
- Task-chat E2E: 2 `@` and `#` Escape scenarios passed.
|
||||
- Frontend typecheck and changed-file ESLint passed.
|
||||
|
||||
## Output contract
|
||||
|
||||
Report RED/GREEN results, exact commands, changed files, preview verification, and remaining risks.
|
||||
@@ -104,6 +104,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su
|
||||
| [agent-generated-titles](tasks/agent-generated-titles.md) | approved |
|
||||
| [task-create-executor-default](tasks/task-create-executor-default.md) | approved |
|
||||
| [task-create-workflow-memory](tasks/task-create-workflow-memory.md) | approved |
|
||||
| [task-create-escape-dismissal](tasks/task-create-escape-dismissal.md) | complete |
|
||||
| [repository-sets](repository-sets/spec.md) | building |
|
||||
| [external-id-idempotency](tasks/external-id-idempotency/spec.md) | draft |
|
||||
| [prompt-attachments](tasks/prompt-attachments.md) | draft |
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
status: complete
|
||||
created: 2026-08-19
|
||||
owner: kandev
|
||||
---
|
||||
|
||||
# Create Task Escape Dismissal
|
||||
|
||||
## Why
|
||||
|
||||
Users can press Escape to close an autocomplete menu while they write a task prompt.
|
||||
The same key must not close the Create Task dialog and discard the active task-creation flow.
|
||||
|
||||
## What
|
||||
|
||||
- The standard Create Task dialog remains open when the user presses Escape.
|
||||
- This rule applies when an autocomplete menu is open and when no menu is open.
|
||||
- If an autocomplete menu is open, Escape closes that menu, keeps the dialog open, and leaves
|
||||
focus in the prompt textarea so typing can continue.
|
||||
- Escape does not clear the prompt, the autocomplete query, attachments, or other form values.
|
||||
- The footer Cancel action and other existing dismissal actions keep their current behavior.
|
||||
- The rule applies to all standard task-creation entry points and all viewport sizes.
|
||||
|
||||
## Scenarios
|
||||
|
||||
- **GIVEN** the Create Task dialog is open without an autocomplete menu, **WHEN** the user presses Escape, **THEN** the dialog stays open with its form values unchanged.
|
||||
- **GIVEN** the Create Task dialog has an open `@` autocomplete menu, **WHEN** the user presses Escape, **THEN** the menu closes, the dialog stays open, and the prompt textarea keeps focus.
|
||||
- **GIVEN** a task-chat `@` or `#` autocomplete menu is open, **WHEN** the user presses Escape, **THEN** the menu closes and the chat editor keeps focus so typing can continue.
|
||||
- **GIVEN** the Create Task dialog is open on a phone with a hardware keyboard, **WHEN** the user presses Escape, **THEN** the full-height dialog stays open.
|
||||
- **GIVEN** the Create Task dialog is open, **WHEN** the user selects Cancel, **THEN** the dialog closes through the existing cancellation path.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Changing Escape behavior in Edit Task, New Agent, Quick Chat, or other dialogs.
|
||||
- Changing autocomplete search, selection, or trigger characters.
|
||||
- Changing pointer, outside-click, browser-back, or Cancel dismissal behavior.
|
||||
- Changing the dialog layout, general focus order, scroll owner, or mobile composition beyond
|
||||
retaining prompt focus after autocomplete dismissal.
|
||||
|
||||
## Implementation plan
|
||||
|
||||
- [Create Task Escape Dismissal](../../plans/task-create-escape-dismissal/plan.md)
|
||||
Reference in New Issue
Block a user