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
This commit is contained in:
gsxdsm
2026-06-25 14:19:40 -07:00
parent 4349f6a72f
commit 3513d5f6f8
5 changed files with 144 additions and 2 deletions

View File

@@ -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.

View File

@@ -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: <issue-url>`; selecting a pull request creates a PR-focused prompt with `PR: <pr-url>` 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: <issue-url>`; selecting a pull request creates a PR-focused prompt with `PR: <pr-url>` 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

View File

@@ -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 {

View File

@@ -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", () => {

View File

@@ -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", () => {