From 61bde7ab9eb0acc4ff75a20776485ceeea982d7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Wed, 19 Aug 2026 17:03:03 +0100 Subject: [PATCH] fix(web): keep Create Task open on Escape (#2803) --- .../task-create-dialog-selectors.test.tsx | 17 ++- .../task-create-dialog-selectors.tsx | 15 ++- .../components/task-create-dialog.test.tsx | 40 +++++- apps/web/components/task-create-dialog.tsx | 3 + .../chat/entity-reference-composer.spec.ts | 7 + apps/web/e2e/tests/improve-kandev.spec.ts | 5 +- .../office/sidebar-office-gating.spec.ts | 2 +- apps/web/e2e/tests/task/create-task.spec.ts | 4 +- .../web/e2e/tests/task/enhance-prompt.spec.ts | 2 +- .../task/mobile-task-create-escape.spec.ts | 50 +++++++ ...task-create-prompt-autocomplete-qa.spec.ts | 34 ++++- .../workflow/workflow-agent-profile.spec.ts | 4 +- apps/web/hooks/use-inline-mention.test.ts | 47 +++++++ apps/web/hooks/use-inline-mention.ts | 11 +- .../task-create-escape-dismissal/plan.md | 125 ++++++++++++++++++ .../task-01-guard-dialog-escape.md | 112 ++++++++++++++++ .../task-02-capture-autocomplete-escape.md | 82 ++++++++++++ docs/specs/INDEX.md | 1 + .../tasks/task-create-escape-dismissal.md | 42 ++++++ 19 files changed, 586 insertions(+), 17 deletions(-) create mode 100644 apps/web/e2e/tests/task/mobile-task-create-escape.spec.ts create mode 100644 docs/plans/task-create-escape-dismissal/plan.md create mode 100644 docs/plans/task-create-escape-dismissal/task-01-guard-dialog-escape.md create mode 100644 docs/plans/task-create-escape-dismissal/task-02-capture-autocomplete-escape.md create mode 100644 docs/specs/tasks/task-create-escape-dismissal.md diff --git a/apps/web/components/task-create-dialog-selectors.test.tsx b/apps/web/components/task-create-dialog-selectors.test.tsx index 9e2dac2d16..316cceca90 100644 --- a/apps/web/components/task-create-dialog-selectors.test.tsx +++ b/apps/web/components/task-create-dialog-selectors.test.tsx @@ -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(""); diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index 2dd6aea98b..3ebc317dc0 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -829,15 +829,18 @@ function useTextareaHandlers( mentionHandleChange(e.target.value, e.target.selectionStart), [mentionHandleChange], ); + const handleKeyDownCapture = useCallback( + (e: React.KeyboardEvent) => mentionHandleKeyDown(e), + [mentionHandleKeyDown], + ); const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { - 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) { @@ -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" diff --git a/apps/web/components/task-create-dialog.test.tsx b/apps/web/components/task-create-dialog.test.tsx index c16b7776e1..73d7a5c6bc 100644 --- a/apps/web/components/task-create-dialog.test.tsx +++ b/apps/web/components/task-create-dialog.test.tsx @@ -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 }) =>
{children}
, - DialogContent: ({ children }: { children: ReactNode }) =>
{children}
, + DialogContent: ({ + children, + onEscapeKeyDown, + }: { + children: ReactNode; + onEscapeKeyDown?: (event: EscapeEvent) => void; + }) => { + dialogEscapeHandler = onEscapeKeyDown; + return
{children}
; + }, DialogHeader: ({ children }: { children: ReactNode }) =>
{children}
, DialogFooter: ({ children }: { children: ReactNode }) =>
{children}
, })); @@ -324,10 +336,11 @@ function buildMockFs(initialDescription = ORIGINAL_PROMPT): DialogFormState { }; } -function renderDialog() { +function renderDialog(mode: "create" | "edit" | "session" = "create") { return render( 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) | undefined; diff --git a/apps/web/components/task-create-dialog.tsx b/apps/web/components/task-create-dialog.tsx index a3c8c56771..bb4b33d863 100644 --- a/apps/web/components/task-create-dialog.tsx +++ b/apps/web/components/task-create-dialog.tsx @@ -253,6 +253,9 @@ export function TaskCreateDialog(props: TaskCreateDialogProps) { { + if (setup.isCreateMode) event.preventDefault(); + }} data-testid="create-task-dialog" data-webkit-safe-motion="true" showCloseButton={false} diff --git a/apps/web/e2e/tests/chat/entity-reference-composer.spec.ts b/apps/web/e2e/tests/chat/entity-reference-composer.spec.ts index f659853dc3..187d4f91e9 100644 --- a/apps/web/e2e/tests/chat/entity-reference-composer.spec.ts +++ b/apps/web/e2e/tests/chat/entity-reference-composer.spec.ts @@ -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( diff --git a/apps/web/e2e/tests/improve-kandev.spec.ts b/apps/web/e2e/tests/improve-kandev.spec.ts index 932602475b..305113ae4d 100644 --- a/apps/web/e2e/tests/improve-kandev.spec.ts +++ b/apps/web/e2e/tests/improve-kandev.spec.ts @@ -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(); diff --git a/apps/web/e2e/tests/office/sidebar-office-gating.spec.ts b/apps/web/e2e/tests/office/sidebar-office-gating.spec.ts index 69a852b14f..beb50b24df 100644 --- a/apps/web/e2e/tests/office/sidebar-office-gating.spec.ts +++ b/apps/web/e2e/tests/office/sidebar-office-gating.spec.ts @@ -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 diff --git a/apps/web/e2e/tests/task/create-task.spec.ts b/apps/web/e2e/tests/task/create-task.spec.ts index 9d2f40b001..06c08b8ae8 100644 --- a/apps/web/e2e/tests/task/create-task.spec.ts +++ b/apps/web/e2e/tests/task/create-task.spec.ts @@ -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 diff --git a/apps/web/e2e/tests/task/enhance-prompt.spec.ts b/apps/web/e2e/tests/task/enhance-prompt.spec.ts index c181320f5d..738bfce704 100644 --- a/apps/web/e2e/tests/task/enhance-prompt.spec.ts +++ b/apps/web/e2e/tests/task/enhance-prompt.spec.ts @@ -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); diff --git a/apps/web/e2e/tests/task/mobile-task-create-escape.spec.ts b/apps/web/e2e/tests/task/mobile-task-create-escape.spec.ts new file mode 100644 index 0000000000..0727fd035f --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-task-create-escape.spec.ts @@ -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"); +}); diff --git a/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts b/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts index 08f79e8aef..6536df1e88 100644 --- a/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts +++ b/apps/web/e2e/tests/task/task-create-prompt-autocomplete-qa.spec.ts @@ -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 }) => { diff --git a/apps/web/e2e/tests/workflow/workflow-agent-profile.spec.ts b/apps/web/e2e/tests/workflow/workflow-agent-profile.spec.ts index a226103ab4..6287abfda4 100644 --- a/apps/web/e2e/tests/workflow/workflow-agent-profile.spec.ts +++ b/apps/web/e2e/tests/workflow/workflow-agent-profile.spec.ts @@ -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(); diff --git a/apps/web/hooks/use-inline-mention.test.ts b/apps/web/hooks/use-inline-mention.test.ts index e2b831abcb..e92ced92ef 100644 --- a/apps/web/hooks/use-inline-mention.test.ts +++ b/apps/web/hooks/use-inline-mention.test.ts @@ -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." }; diff --git a/apps/web/hooks/use-inline-mention.ts b/apps/web/hooks/use-inline-mention.ts index c65caea17e..727bbb359e 100644 --- a/apps/web/hooks/use-inline-mention.ts +++ b/apps/web/hooks/use-inline-mention.ts @@ -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 { diff --git a/docs/plans/task-create-escape-dismissal/plan.md b/docs/plans/task-create-escape-dismissal/plan.md new file mode 100644 index 0000000000..88cc880460 --- /dev/null +++ b/docs/plans/task-create-escape-dismissal/plan.md @@ -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. diff --git a/docs/plans/task-create-escape-dismissal/task-01-guard-dialog-escape.md b/docs/plans/task-create-escape-dismissal/task-01-guard-dialog-escape.md new file mode 100644 index 0000000000..d42161d38c --- /dev/null +++ b/docs/plans/task-create-escape-dismissal/task-01-guard-dialog-escape.md @@ -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. diff --git a/docs/plans/task-create-escape-dismissal/task-02-capture-autocomplete-escape.md b/docs/plans/task-create-escape-dismissal/task-02-capture-autocomplete-escape.md new file mode 100644 index 0000000000..9284bf08ee --- /dev/null +++ b/docs/plans/task-create-escape-dismissal/task-02-capture-autocomplete-escape.md @@ -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. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 8dd8f3bfc8..a38823a7bb 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -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 | diff --git a/docs/specs/tasks/task-create-escape-dismissal.md b/docs/specs/tasks/task-create-escape-dismissal.md new file mode 100644 index 0000000000..626e04e253 --- /dev/null +++ b/docs/specs/tasks/task-create-escape-dismissal.md @@ -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)