From 3513d5f6f8ac5ae2b86a0294ce60672b8365dc07 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Thu, 25 Jun 2026 14:19:40 -0700 Subject: [PATCH] FN-7002: fix mobile New Task dialog affordances Keep mobile New Task dialog controls reachable and tappable under keyboard-constrained viewports. - Re-enable hit testing on the New Task sheet while preserving desktop overlay click-through behavior. - Bound GitHub, dependency, and agent picker popups so mobile users can scroll them inside the sheet. - Add regression coverage for mobile dialog affordances and document the mobile behavior. - Add a patch changeset for the published Fusion package. Files changed: .changeset/fn-7002-mobile-new-task-affordances.md | 7 ++ docs/dashboard-guide.md | 2 +- packages/dashboard/app/components/NewTaskModal.css | 18 +++++ .../app/components/__tests__/NewTaskModal.test.tsx | 87 +++++++++++++++++++++- .../__tests__/core-modals-mobile.test.tsx | 32 ++++++++ 5 files changed, 144 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-7002 Fusion-Task-Lineage: 31c2504e-56fd-4395-bbe3-e753aab04e23 --- .../fn-7002-mobile-new-task-affordances.md | 7 ++ docs/dashboard-guide.md | 2 +- .../dashboard/app/components/NewTaskModal.css | 18 ++++ .../__tests__/NewTaskModal.test.tsx | 87 ++++++++++++++++++- .../__tests__/core-modals-mobile.test.tsx | 32 +++++++ 5 files changed, 144 insertions(+), 2 deletions(-) create mode 100644 .changeset/fn-7002-mobile-new-task-affordances.md diff --git a/.changeset/fn-7002-mobile-new-task-affordances.md b/.changeset/fn-7002-mobile-new-task-affordances.md new file mode 100644 index 0000000000..b3d04cc7c4 --- /dev/null +++ b/.changeset/fn-7002-mobile-new-task-affordances.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Keep New Task mobile dialog controls tappable while the keyboard is open. +category: fix +dev: Restores hit testing for the NewTaskModal sheet and bounds mobile picker dropdowns. diff --git a/docs/dashboard-guide.md b/docs/dashboard-guide.md index cf75b63f5c..ab518cd4c7 100644 --- a/docs/dashboard-guide.md +++ b/docs/dashboard-guide.md @@ -324,7 +324,7 @@ Rules: The dialog also exposes the board quick-add AI handoffs: **Plan** opens Planning Mode with the current description, and **Subtask** opens Subtask Breakdown with the current description when **Settings → Experimental Features → Subtask Breakdown** is enabled. The Subtask handoff is hidden by default; visible handoff buttons remain disabled until the description has content, matching the quick-add row behavior. **Execution mode** is available in the New Task dialog as well as quick entry, so users can choose Fast or standard execution before creating a task from either surface. -The full **New Task** dialog includes a compact **GitHub issue or PR** picker near the description. It detects GitHub remotes for the current project, auto-selects a single remote or `origin`, and asks you to choose a remote when multiple non-`origin` remotes are available. Selecting an issue replaces the description with a prompt that tells the executor to fetch/read the issue and includes `Source: `; selecting a pull request creates a PR-focused prompt with `PR: ` and explicit instructions to inspect the PR conversation, review comments, checks, and changed files, then resolve or address actionable review comments. If you already typed a description, Fusion asks before replacing it. This picker only seeds the prompt; it does not import, close, or comment on GitHub items. +The full **New Task** dialog includes a compact **GitHub issue or PR** picker near the description. It detects GitHub remotes for the current project, auto-selects a single remote or `origin`, and asks you to choose a remote when multiple non-`origin` remotes are available. Selecting an issue replaces the description with a prompt that tells the executor to fetch/read the issue and includes `Source: `; selecting a pull request creates a PR-focused prompt with `PR: ` and explicit instructions to inspect the PR conversation, review comments, checks, and changed files, then resolve or address actionable review comments. If you already typed a description, Fusion asks before replacing it. This picker only seeds the prompt; it does not import, close, or comment on GitHub items. On mobile, the full-screen New Task sheet keeps the GitHub picker, dependency picker, agent picker, quick handoff buttons, and action row tappable and scrollable even when the keyboard reduces the visual viewport. ## Chat View diff --git a/packages/dashboard/app/components/NewTaskModal.css b/packages/dashboard/app/components/NewTaskModal.css index 5412fc77de..b652bf3c18 100644 --- a/packages/dashboard/app/components/NewTaskModal.css +++ b/packages/dashboard/app/components/NewTaskModal.css @@ -1,6 +1,11 @@ /* === New Task Modal === */ +/* +FNXC:NewTaskMobileAffordances 2026-06-25-12:22: +The transparent New Task overlay intentionally passes clicks through for desktop floating mode, so the panel itself must always opt back into hit testing. Mobile uses the non-floating full-screen sheet and must keep every desktop affordance (GitHub picker, quick buttons, dropdowns, and action row) tappable and scroll-reachable even when the keyboard constrains the visual viewport. +*/ .new-task-modal { min-height: min(520px, 80vh); + pointer-events: auto; } /* @@ -647,6 +652,10 @@ The GitHub reference picker is a compact prompt-seeding helper inside the primar left: 0; right: 0; max-width: 100%; + max-height: min(40dvh, calc(var(--space-2xl) * 6)); + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; } .task-form-description-actions { @@ -673,6 +682,11 @@ The GitHub reference picker is a compact prompt-seeding helper inside the primar margin: 0 var(--space-md) var(--space-sm); } + .new-task-github-reference-picker__remote-select, + .new-task-github-reference-picker__select { + min-height: calc(var(--space-xl) + var(--space-sm)); + } + .new-task-quick-fields .dep-trigger { width: 100%; min-height: 36px; @@ -682,6 +696,10 @@ The GitHub reference picker is a compact prompt-seeding helper inside the primar left: 0; right: 0; max-width: 100%; + max-height: min(40dvh, calc(var(--space-2xl) * 6)); + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; } .task-form-more-options { diff --git a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx index 7fc27fbdef..9c1c9b8e6c 100644 --- a/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx +++ b/packages/dashboard/app/components/__tests__/NewTaskModal.test.tsx @@ -4,7 +4,7 @@ import type { ComponentProps } from "react"; import { readFileSync } from "node:fs"; import { NewTaskModal } from "../NewTaskModal"; import type { Task, Column } from "@fusion/core"; -import { apiFetchGitHubIssues, apiFetchGitHubPulls, checkDuplicateTasks, fetchGitRemotes, type BoardWorkflowsPayload } from "../../api"; +import { apiFetchGitHubIssues, apiFetchGitHubPulls, checkDuplicateTasks, fetchAgents, fetchGitRemotes, type BoardWorkflowsPayload } from "../../api"; import { writeBoardWorkflowsCache } from "../../utils/boardWorkflowsCache"; import { writeLastSelectedWorkflowId } from "../../utils/lastSelectedWorkflow"; @@ -383,6 +383,91 @@ describe("NewTaskModal", () => { expect(screen.getByTestId("new-task-github-reference-select")).toBeEnabled(); expect(document.querySelector(".new-task-modal")?.getAttribute("style")).toContain("--keyboard-overlap: 250px"); }); + + it("keeps the mobile sheet hit-testable when the transparent overlay passes through clicks", async () => { + await renderPickerWithData({ viewport: "mobile" }); + await waitFor(() => expect(screen.getByTestId("new-task-github-reference-picker")).toBeInTheDocument()); + + expect(document.querySelector(".new-task-modal--floating")).toBeNull(); + expect(newTaskModalCss).toContain("FNXC:NewTaskMobileAffordances 2026-06-25"); + expect(newTaskModalCss).toMatch(/\.new-task-modal\s*\{[^}]*pointer-events:\s*auto;/s); + }); + + it("keeps GitHub, dependency, and agent popups reachable in the mobile keyboard sheet", async () => { + mockUseMobileKeyboard.mockReturnValue({ + keyboardOpen: true, + keyboardOverlap: 250, + viewportHeight: 400, + viewportOffsetTop: 50, + }); + vi.mocked(fetchAgents).mockResolvedValueOnce([ + { id: "agent-exec", name: "Executor Bot", role: "executor", state: "idle" } as any, + ]); + + await renderPickerWithData({ viewport: "mobile" }); + + await waitFor(() => expect(screen.getByTestId("new-task-github-reference-select")).toBeEnabled()); + expect(screen.getByTestId("new-task-github-reference-picker")).toBeVisible(); + expect(screen.getByText("Issue #12 — Crash on startup")).toBeInTheDocument(); + expect(screen.getByText("PR #34 — Fix login")).toBeInTheDocument(); + expect(screen.getByTestId("task-form-inline-github")).toBeVisible(); + expect(screen.getByTestId("task-form-inline-models")).toBeVisible(); + expect(screen.getByTestId("task-form-inline-node")).toBeVisible(); + expect(screen.getByTestId("task-form-inline-priority")).toBeVisible(); + expect(screen.getByRole("button", { name: "Create Task" })).toBeVisible(); + + fireEvent.click(screen.getByTestId("dep-trigger")); + expect(screen.getByPlaceholderText("Search tasks…")).toBeVisible(); + expect(screen.getByText("No available tasks")).toBeVisible(); + const depDropdown = document.querySelector(".new-task-quick-fields .dep-dropdown"); + expect(depDropdown).not.toBeNull(); + expect(depDropdown?.closest(".new-task-modal")).toBeTruthy(); + + fireEvent.click(screen.getByTestId("new-task-agent-button")); + await waitFor(() => expect(screen.getByTestId("agent-option-agent-exec")).toBeVisible()); + const agentDropdown = document.querySelector(".new-task-quick-fields .agent-picker-dropdown"); + expect(agentDropdown).not.toBeNull(); + expect(agentDropdown?.closest(".new-task-modal")).toBeTruthy(); + expect(document.querySelector(".new-task-modal")?.getAttribute("style")).toContain("--keyboard-overlap: 250px"); + }); + + it("renders mobile GitHub data states without clipping or absent picker controls", async () => { + vi.mocked(fetchGitRemotes).mockImplementationOnce(() => new Promise(() => undefined)); + const loadingRender = renderNewTaskModal({ projectId: "project-1" }); + await waitFor(() => expect(screen.getByTestId("new-task-github-reference-status")).toHaveTextContent("Loading GitHub remotes")); + expect(screen.getByTestId("new-task-github-reference-picker")).toBeVisible(); + loadingRender.unmount(); + + vi.mocked(fetchGitRemotes).mockResolvedValueOnce([upstreamRemote, { ...originRemote, name: "fork" }]); + const multipleRemoteRender = renderNewTaskModal({ projectId: "project-1" }); + await waitFor(() => expect(screen.getByTestId("new-task-github-remote-select")).toBeVisible()); + expect(screen.getByText("Choose a GitHub remote before selecting an issue or pull request.")).toBeVisible(); + multipleRemoteRender.unmount(); + + vi.mocked(fetchGitRemotes).mockResolvedValueOnce([originRemote]); + vi.mocked(apiFetchGitHubIssues).mockImplementationOnce(() => new Promise(() => undefined)); + vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce([]); + const referenceLoadingRender = renderNewTaskModal({ projectId: "project-1" }); + await waitFor(() => expect(screen.getByTestId("new-task-github-reference-status")).toHaveTextContent("Loading open issues and pull requests")); + expect(screen.getByTestId("new-task-github-reference-picker")).toBeVisible(); + referenceLoadingRender.unmount(); + + vi.mocked(fetchGitRemotes).mockResolvedValueOnce([originRemote]); + vi.mocked(apiFetchGitHubIssues).mockRejectedValueOnce(new Error("GitHub auth required")); + vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce([]); + const authErrorRender = renderNewTaskModal({ projectId: "project-1" }); + await waitFor(() => expect(screen.getByTestId("new-task-github-reference-status")).toHaveTextContent("GitHub auth required")); + expect(screen.getByTestId("new-task-github-reference-picker")).toBeVisible(); + authErrorRender.unmount(); + + vi.mocked(fetchGitRemotes).mockResolvedValueOnce([originRemote]); + vi.mocked(apiFetchGitHubIssues).mockResolvedValueOnce([{ ...issue, number: 7 }]); + vi.mocked(apiFetchGitHubPulls).mockResolvedValueOnce([{ ...pull, number: 7 }]); + renderNewTaskModal({ projectId: "project-1" }); + await waitFor(() => expect(screen.getByText("Issue #7 — Crash on startup")).toBeVisible()); + expect(screen.getByText("PR #7 — Fix login")).toBeVisible(); + expect(screen.getByTestId("new-task-github-reference-select")).toBeVisible(); + }); }); it("exposes New Task dialog quick-add affordance parity when AI handoff callbacks are supplied", () => { diff --git a/packages/dashboard/app/components/__tests__/core-modals-mobile.test.tsx b/packages/dashboard/app/components/__tests__/core-modals-mobile.test.tsx index 9cf89b1359..0e19e0fc94 100644 --- a/packages/dashboard/app/components/__tests__/core-modals-mobile.test.tsx +++ b/packages/dashboard/app/components/__tests__/core-modals-mobile.test.tsx @@ -528,6 +528,38 @@ describe("core modals mobile css coverage", () => { ); expect(quickFieldsTriggerMatch).not.toBeNull(); expect(quickFieldsTriggerMatch![0]).toContain("min-height: 36px"); + + const githubSelectRule = mobileBlock.match( + /\.new-task-github-reference-picker__remote-select,\s*\.new-task-github-reference-picker__select\s*\{[^}]+\}/, + ); + expect(githubSelectRule).not.toBeNull(); + expect(githubSelectRule![0]).toContain("min-height: calc(var(--space-xl) + var(--space-sm))"); + }); + + it("NewTaskModal: mobile popup containers stay hit-testable and scrollable inside the sheet", () => { + const css = loadAllAppCss(); + const mobileBlock = getMainMobileBlock(css); + const baseModalRule = getFirstRuleBlock(css, ".new-task-modal"); + + expect(baseModalRule).toContain("pointer-events: auto"); + + const taskFormDropdownRule = mobileBlock.match( + /\.task-form \.dep-dropdown\s*\{[^}]+\}/, + ); + expect(taskFormDropdownRule).not.toBeNull(); + expect(taskFormDropdownRule![0]).toContain("left: 0"); + expect(taskFormDropdownRule![0]).toContain("right: 0"); + expect(taskFormDropdownRule![0]).toContain("overflow-y: auto"); + expect(taskFormDropdownRule![0]).toContain("overscroll-behavior: contain"); + + const quickFieldsDropdownRule = mobileBlock.match( + /\.new-task-quick-fields \.dep-dropdown\s*\{[^}]+\}/, + ); + expect(quickFieldsDropdownRule).not.toBeNull(); + expect(quickFieldsDropdownRule![0]).toContain("left: 0"); + expect(quickFieldsDropdownRule![0]).toContain("right: 0"); + expect(quickFieldsDropdownRule![0]).toContain("overflow-y: auto"); + expect(quickFieldsDropdownRule![0]).toContain("overscroll-behavior: contain"); }); it("NewTaskModal: modal body uses token-based padding on mobile", () => {