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:
7
.changeset/fn-7002-mobile-new-task-affordances.md
Normal file
7
.changeset/fn-7002-mobile-new-task-affordances.md
Normal 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.
|
||||||
@@ -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 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
|
## Chat View
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
/* === New Task Modal === */
|
/* === 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 {
|
.new-task-modal {
|
||||||
min-height: min(520px, 80vh);
|
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;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
max-width: 100%;
|
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 {
|
.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);
|
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 {
|
.new-task-quick-fields .dep-trigger {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 36px;
|
min-height: 36px;
|
||||||
@@ -682,6 +696,10 @@ The GitHub reference picker is a compact prompt-seeding helper inside the primar
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
max-width: 100%;
|
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 {
|
.task-form-more-options {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { ComponentProps } from "react";
|
|||||||
import { readFileSync } from "node:fs";
|
import { readFileSync } from "node:fs";
|
||||||
import { NewTaskModal } from "../NewTaskModal";
|
import { NewTaskModal } from "../NewTaskModal";
|
||||||
import type { Task, Column } from "@fusion/core";
|
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 { writeBoardWorkflowsCache } from "../../utils/boardWorkflowsCache";
|
||||||
import { writeLastSelectedWorkflowId } from "../../utils/lastSelectedWorkflow";
|
import { writeLastSelectedWorkflowId } from "../../utils/lastSelectedWorkflow";
|
||||||
|
|
||||||
@@ -383,6 +383,91 @@ describe("NewTaskModal", () => {
|
|||||||
expect(screen.getByTestId("new-task-github-reference-select")).toBeEnabled();
|
expect(screen.getByTestId("new-task-github-reference-select")).toBeEnabled();
|
||||||
expect(document.querySelector(".new-task-modal")?.getAttribute("style")).toContain("--keyboard-overlap: 250px");
|
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", () => {
|
it("exposes New Task dialog quick-add affordance parity when AI handoff callbacks are supplied", () => {
|
||||||
|
|||||||
@@ -528,6 +528,38 @@ describe("core modals mobile css coverage", () => {
|
|||||||
);
|
);
|
||||||
expect(quickFieldsTriggerMatch).not.toBeNull();
|
expect(quickFieldsTriggerMatch).not.toBeNull();
|
||||||
expect(quickFieldsTriggerMatch![0]).toContain("min-height: 36px");
|
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", () => {
|
it("NewTaskModal: modal body uses token-based padding on mobile", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user