FN-6940: expose New Task quick-add actions

Expose the regular New Task dialog's primary creation and quick-add controls in one visible action row.

- Move the Create Task submit affordance into TaskForm's create-mode action cluster.
- Promote GitHub tracking, workflow, model, node, fast mode, attach, priority, plan, and subtask controls as inline quick-add buttons.
- Add responsive spacing and mobile touch-target coverage for the expanded action row.
- Extend NewTaskModal and TaskForm tests around the promoted controls and submission flow.

Files changed:
 packages/dashboard/app/components/NewTaskModal.css |  12 ++
 packages/dashboard/app/components/NewTaskModal.tsx |  10 +-
 packages/dashboard/app/components/TaskForm.tsx     | 110 +++++++++++++++-
 .../app/components/__tests__/NewTaskModal.test.tsx | 139 ++++++++++++++++++---
 .../app/components/__tests__/TaskForm.test.tsx     |   3 +
 .../__tests__/core-modals-mobile.test.tsx          |   8 +-
 6 files changed, 250 insertions(+), 32 deletions(-)

Fusion-Task-Id: FN-6940

Fusion-Task-Lineage: 8329c432-5e01-40aa-a3da-c13754a74393
This commit is contained in:
gsxdsm
2026-06-23 21:48:01 -07:00
parent 038ac3060b
commit 1f0a48582d
6 changed files with 249 additions and 31 deletions

View File

@@ -187,6 +187,13 @@ Edge + corner resize handles. touch-action:none keeps the drag from being hijack
.task-form-description-actions .btn {
border-color: var(--border);
align-items: center;
gap: var(--space-xs);
white-space: nowrap;
}
.task-form-action-icon {
vertical-align: middle;
}
.task-form-more-options-toggle {
@@ -595,6 +602,11 @@ Edge + corner resize handles. touch-action:none keeps the drag from being hijack
gap: var(--space-xs);
}
.task-form-description-actions .btn {
min-height: 36px;
flex: 1 1 auto;
}
.task-form-more-options-toggle {
margin: 0 var(--space-md) var(--space-sm);
width: calc(100% - var(--space-md) * 2);

View File

@@ -886,6 +886,9 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
onGithubTrackingEnabledChange={setGithubTrackingEnabled}
githubRepoOverride={githubRepoOverride}
onGithubRepoOverrideChange={setGithubRepoOverride}
onCreateSubmit={handleSubmit}
createSubmitLabel={isSubmitting ? t("newTaskModal.creating", "Creating...") : t("newTaskModal.createTask", "Create Task")}
createSubmitDisabled={!description.trim() || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection}
renderBelowPrimary={quickFields}
hideDependencies={true}
autoExpandMoreOptionsOnSelection={false}
@@ -901,13 +904,6 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
<button className="btn btn-sm" onClick={handleClose} disabled={isSubmitting}>
{t("actions.cancel", "Cancel")}
</button>
<button
className="btn btn-primary btn-sm"
onClick={handleSubmit}
disabled={!description.trim() || isSubmitting || githubRepoOverrideInvalid || hasInvalidBranchSelection}
>
{isSubmitting ? t("newTaskModal.creating", "Creating...") : t("newTaskModal.createTask", "Create Task")}
</button>
</div>
</div>
</div>

View File

@@ -8,8 +8,9 @@ import { applyPresetToSelection, getRecommendedPresetForSize } from "../utils/mo
import { CustomModelDropdown } from "./CustomModelDropdown";
import { NodeHealthDot } from "./NodeHealthDot";
import { LoadingSpinner } from "./LoadingSpinner";
import { Sparkles, ChevronUp, ChevronDown, Maximize2, Minimize2, Paperclip, Flag, Zap } from "lucide-react";
import { Sparkles, ChevronUp, ChevronDown, Maximize2, Minimize2, Paperclip, Flag, Zap, Brain, Server } from "lucide-react";
import { REPO_OVERRIDE_RE, resolveEffectiveGithubRepoDefault } from "./githubTracking";
import { ProviderIcon } from "./ProviderIcon";
function getNodeStatusLabel(status: NodeInfo["status"], t: (key: string, defaultValue: string) => string): string {
if (status === "online") return t("taskForm.nodeStatusOnline", "Online");
@@ -138,6 +139,12 @@ export interface TaskFormProps {
onSubtaskBreakdown?: (description: string, workflowId?: string | null) => void;
onClose?: () => void;
// Create-mode primary submission. NewTaskModal owns duplicate checks and payload shaping;
// TaskForm only places the visible Create affordance in the quick-action row.
onCreateSubmit?: () => void;
createSubmitLabel?: string;
createSubmitDisabled?: boolean;
/** Optional content to render between the primary section and the "More options" toggle. */
renderBelowPrimary?: React.ReactNode;
/** Optional content to render inside "More options" below Model Configuration. */
@@ -204,6 +211,9 @@ export function TaskForm({
onPlanningMode,
onSubtaskBreakdown,
onClose,
onCreateSubmit,
createSubmitLabel,
createSubmitDisabled,
renderBelowPrimary,
renderBelowModelConfiguration,
hideDependencies,
@@ -697,6 +707,22 @@ export function TaskForm({
// U6/R3: the project default workflow id (preselected + "(default)" badged).
const defaultWorkflowId = settings?.defaultWorkflowId ?? null;
const selectedWorkflow = selectedWorkflowId === null
? null
: workflows.find((workflow) => workflow.id === (selectedWorkflowId ?? defaultWorkflowId));
const workflowInlineLabel = selectedWorkflowId === null
? t("taskForm.workflowNone", "No workflow")
: selectedWorkflow?.name ?? t("taskForm.workflowInlineDefault", "Normal");
const selectedNode = (nodeOptions ?? []).find((node) => node.id === nodeId);
const nodeInlineLabel = selectedNode?.name ?? t("taskForm.nodeInlineDefault", "Node");
const modelInlineLabel = selectedPreset?.name ?? (presetMode === "custom" ? t("taskForm.modelsCustom", "Models") : t("taskForm.modelsDefault", "Models"));
const revealAdvancedControl = useCallback((selector: string) => {
if (!forceMoreOptionsOpen) setShowMoreOptions(true);
window.setTimeout(() => {
document.querySelector<HTMLElement>(selector)?.focus();
}, 0);
}, [forceMoreOptionsOpen]);
const availableDeps = tasks
.filter((t) => !dependencies.includes(t.id))
@@ -847,9 +873,23 @@ export function TaskForm({
- Fast → toggles executionMode standard⇄fast via onExecutionModeChange (mirrors QuickEntryBox quick-entry-fast-toggle).
- Priority → cycles through TASK_PRIORITIES via onPriorityChange (Flag affordance).
Plan/Subtask remain gated on their handoff callbacks. Model selectors, branch/base, node, review level, and GitHub tracking stay in the Advanced disclosure.
FNXC:NewTaskDialogAffordances 2026-06-23-21:20:
The regular New Task dialog must visibly expose the screenshot quick-add button contract in the immediate action cluster while Advanced remains the deep configuration editor. TaskForm hosts the cluster so create payload state has one source of truth; NewTaskModal only supplies the submit handler and its existing dependency/agent quick controls.
*/}
{mode === "create" && (
<div className="task-form-description-actions" data-testid="task-form-description-actions">
{onCreateSubmit && (
<button
type="button"
className="btn btn-primary btn-sm"
onClick={onCreateSubmit}
disabled={disabled || createSubmitDisabled}
data-testid="task-form-inline-create"
>
{createSubmitLabel ?? t("taskForm.createTask", "Create")}
</button>
)}
{onPlanningMode && (
<button
type="button"
@@ -898,7 +938,7 @@ export function TaskForm({
data-testid="task-form-inline-attach"
title={t("taskForm.attachScreenshot", "Attach Screenshot")}
>
<Paperclip size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
<Paperclip size={12} className="task-form-action-icon" />
{pendingImages.length > 0
? t("taskForm.attachCount", "Attach ({{count}})", { count: pendingImages.length })
: t("taskForm.attach", "Attach")}
@@ -915,11 +955,72 @@ export function TaskForm({
data-testid="task-form-inline-fast"
title={t("taskForm.toggleFastMode", "Toggle fast execution mode")}
>
<Zap size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
<Zap size={12} className="task-form-action-icon" />
{t("taskForm.fast", "Fast")}
</button>
)}
{/* FNXC:NewTaskDialogAffordances 2026-06-23-21:31: GitHub/workflow/models/node are promoted as visible chips that mutate or focus the same Advanced controls instead of duplicating create-payload state. */}
{onGithubTrackingEnabledChange && (
<button
type="button"
className={`btn btn-sm ${githubTrackingEnabled ? "btn-primary" : ""}`}
onClick={() => {
githubTrackingDefaultAppliedRef.current = true;
onGithubTrackingEnabledChange(!githubTrackingEnabled);
}}
aria-pressed={githubTrackingEnabled === true}
disabled={disabled}
data-testid="task-form-inline-github"
title={t("taskForm.githubTrackingLabel", "GitHub Tracking")}
>
<ProviderIcon provider="github" size="sm" />
{t("taskForm.githubInline", "GitHub")}
</button>
)}
{onWorkflowIdChange && (
<button
type="button"
className="btn btn-sm"
onClick={() => revealAdvancedControl("#task-workflow-select, [data-testid='task-workflow-cta']")}
disabled={disabled}
data-testid="task-form-inline-workflow"
aria-label={t("taskForm.workflowInlineAria", "Choose workflow: {{workflow}}", { workflow: workflowInlineLabel })}
title={t("taskForm.workflowLabel", "Workflow")}
>
{workflowInlineLabel}
</button>
)}
<button
type="button"
className="btn btn-sm"
onClick={() => revealAdvancedControl("#model-preset, #executor-model")}
disabled={disabled}
data-testid="task-form-inline-models"
aria-label={t("taskForm.modelsInlineAria", "Choose models: {{models}}", { models: modelInlineLabel })}
title={t("taskForm.modelConfigLabel", "Model Configuration")}
>
<Brain size={12} className="task-form-action-icon" />
{modelInlineLabel}
</button>
{onNodeIdChange && (
<button
type="button"
className="btn btn-sm"
onClick={() => revealAdvancedControl("#task-node-select")}
disabled={disabled || nodeOverrideDisabled}
data-testid="task-form-inline-node"
aria-label={t("taskForm.nodeInlineAria", "Choose execution node: {{node}}", { node: nodeInlineLabel })}
title={nodeOverrideDisabled ? nodeOverrideDisabledReason : t("taskForm.nodeOverrideLabel", "Execution Node Override")}
>
{selectedNode ? <NodeHealthDot status={selectedNode.status} compact className="task-form-action-icon" /> : <Server size={12} className="task-form-action-icon" />}
{nodeInlineLabel}
</button>
)}
{/* FNXC:NewTask 2026-06-23-00:10: Priority — cycles TASK_PRIORITIES via onPriorityChange (Flag affordance, same label shape as QuickEntryBox). */}
{onPriorityChange && (
<button
@@ -935,7 +1036,7 @@ export function TaskForm({
data-testid="task-form-inline-priority"
title={t("taskForm.priorityLabel", "Priority")}
>
<Flag size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
<Flag size={12} className="task-form-action-icon" />
{(() => {
const p = priority ?? DEFAULT_TASK_PRIORITY;
return `${p[0].toUpperCase()}${p.slice(1)}`;
@@ -1027,6 +1128,7 @@ export function TaskForm({
<label htmlFor="task-node-select">{t("taskForm.nodeOverrideLabel", "Execution Node Override")}</label>
<select
id="task-node-select"
data-testid="task-node-select"
className="select"
value={nodeId ?? ""}
onChange={(e) => onNodeIdChange(e.target.value || undefined)}

View File

@@ -24,6 +24,13 @@ vi.mock("lucide-react", () => ({
Paperclip: () => null,
Flag: () => null,
Zap: () => null,
Brain: () => null,
Server: () => null,
Cpu: () => null,
}));
vi.mock("../ProviderIcon", () => ({
ProviderIcon: ({ provider }: { provider: string }) => <span data-testid={`provider-icon-${provider}`} />,
}));
// Mock the api module
@@ -157,10 +164,15 @@ describe("NewTaskModal", () => {
expect(screen.getByTestId("dep-trigger")).toBeInTheDocument();
expect(screen.getByTestId("new-task-agent-button")).toBeInTheDocument();
// FNXC:NewTask 2026-06-23-00:10: The common quick-add buttons (Attach, Fast, Priority) are surfaced INLINE next to the actions row and visible immediately.
expect(screen.getByTestId("task-form-inline-attach")).toBeInTheDocument();
expect(screen.getByTestId("task-form-inline-fast")).toBeInTheDocument();
expect(screen.getByTestId("task-form-inline-priority")).toBeInTheDocument();
// FNXC:NewTaskDialogAffordances 2026-06-23-21:47: The regular New Task dialog exposes the screenshot quick-add buttons immediately; detailed selects stay in Advanced.
expect(screen.getByTestId("task-form-inline-create")).toBeVisible();
expect(screen.getByTestId("task-form-inline-attach")).toBeVisible();
expect(screen.getByTestId("task-form-inline-fast")).toBeVisible();
expect(screen.getByTestId("task-form-inline-github")).toBeVisible();
expect(screen.getByTestId("task-form-inline-workflow")).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();
// FNXC:NewTask 2026-06-23-00:10: The DEEP/advanced options now sit behind the collapsed "Advanced" disclosure. Model Configuration / Attachments are NOT shown until the toggle is expanded.
const advancedToggle = screen.getByTestId("task-form-more-options-toggle");
@@ -182,29 +194,54 @@ describe("NewTaskModal", () => {
onSubtaskBreakdown: vi.fn(),
});
const advancedSection = screen.getByTestId("task-form-more-options");
expect(advancedSection).toHaveAttribute("hidden");
// Empty description state: description-gated actions are disabled/absent, while configuration chips stay immediately usable.
expect(screen.getByTestId("task-form-inline-create")).toBeDisabled();
expect(screen.getByTestId("task-form-plan-button")).toBeDisabled();
expect(screen.queryByTestId("refine-button")).toBeNull();
expect(screen.getByTestId("task-form-inline-fast")).toBeVisible();
expect(screen.getByTestId("task-form-inline-github")).toBeVisible();
expect(screen.getByTestId("task-form-inline-workflow")).toBeVisible();
expect(screen.getByTestId("task-form-inline-models")).toBeVisible();
expect(screen.getByTestId("task-form-inline-node")).toBeVisible();
expect(screen.getByTestId("dep-trigger")).toBeVisible();
expect(screen.getByTestId("task-form-inline-attach")).toBeVisible();
expect(screen.getByTestId("new-task-agent-button")).toBeVisible();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Create parity coverage" } });
// Canonical QuickEntryBox action row includes Plan, Subtask, Refine, Deps, Attach, Models, Node, and Agent affordances; the modal maps these to existing TaskForm/quick-field controls instead of duplicating implementations.
// Populated description state: the complete screenshot affordance set is visible without opening Advanced.
expect(screen.getByTestId("task-form-inline-create")).toBeEnabled();
expect(screen.getAllByTestId("task-form-plan-button")).toHaveLength(1);
expect(screen.getAllByTestId("task-form-subtask-button")).toHaveLength(1);
expect(screen.getByTestId("refine-button")).toBeInTheDocument();
expect(screen.getByTestId("dep-trigger")).toBeInTheDocument();
expect(screen.getByTestId("new-task-agent-button")).toBeInTheDocument();
expect(screen.getByTestId("task-form-plan-button")).toBeEnabled();
expect(screen.getByTestId("refine-button")).toBeVisible();
expect(screen.getByTestId("dep-trigger")).toBeVisible();
expect(screen.getByTestId("new-task-agent-button")).toBeVisible();
expect(screen.getByTestId("task-form-inline-attach")).toBeVisible();
expect(screen.getByTestId("task-form-inline-fast")).toBeVisible();
expect(screen.getByTestId("task-form-inline-github")).toBeVisible();
expect(screen.getByTestId("task-form-inline-workflow")).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.getByTestId("task-form-execution-mode-select")).toBeInTheDocument();
expect(screen.getByTestId("task-form-github-tracking")).toBeInTheDocument();
expect(screen.getByTestId("task-priority-select")).toBeInTheDocument();
expect(screen.getByText(/Attachments/i)).toBeInTheDocument();
expect(screen.getByText(/Node Override/i)).toBeInTheDocument();
// Detailed editors remain present only inside Advanced, not duplicated as visible siblings.
expect(advancedSection).toContainElement(screen.getByTestId("task-form-execution-mode-select"));
expect(advancedSection).toContainElement(screen.getByTestId("task-form-github-tracking"));
expect(advancedSection).toContainElement(screen.getByTestId("task-priority-select"));
expect(advancedSection).toContainElement(screen.getByTestId("task-node-select"));
expect(advancedSection).toHaveAttribute("hidden");
});
it("renders the Fast and standard execution-mode affordance inside More options", () => {
it("keeps the detailed Fast/standard execution-mode select inside Advanced", () => {
renderNewTaskModal();
const advancedSection = screen.getByTestId("task-form-more-options");
const select = screen.getByTestId("task-form-execution-mode-select") as HTMLSelectElement;
expect(select).toBeInTheDocument();
expect(advancedSection).toContainElement(select);
expect(advancedSection).toHaveAttribute("hidden");
expect(select).toHaveValue("standard");
expect(Array.from(select.options).map((option) => option.value)).toEqual(["standard", "fast"]);
});
@@ -212,7 +249,7 @@ describe("NewTaskModal", () => {
it("includes executionMode fast in the create payload when Fast is selected", async () => {
const { props } = renderNewTaskModal();
fireEvent.change(screen.getByTestId("task-form-execution-mode-select"), { target: { value: "fast" } });
fireEvent.click(screen.getByTestId("task-form-inline-fast"));
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Fast parity task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
@@ -226,6 +263,68 @@ describe("NewTaskModal", () => {
});
});
it("promoted GitHub, workflow, model, node, deps, agent, attach, and create controls are functional", async () => {
const { fetchWorkflows } = await import("../../api");
vi.mocked(fetchWorkflows).mockResolvedValueOnce([
{
id: "WF-quick",
name: "Quick Lane",
description: "",
kind: "workflow",
ir: { version: "v1", name: "Quick Lane", nodes: [], edges: [] },
layout: {},
createdAt: "",
updatedAt: "",
} as any,
]);
const clickSpy = vi.spyOn(HTMLInputElement.prototype, "click").mockImplementation(() => undefined);
const { props } = renderNewTaskModal({ tasks: [makeTask("FN-777")] });
fireEvent.click(screen.getByTestId("task-form-inline-github"));
expect(screen.getByTestId("task-form-inline-github")).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByTestId("task-form-inline-fast"));
expect(screen.getByTestId("task-form-inline-fast")).toHaveAttribute("aria-pressed", "true");
fireEvent.click(screen.getByTestId("task-form-inline-attach"));
expect(clickSpy).toHaveBeenCalled();
fireEvent.click(screen.getByTestId("dep-trigger"));
expect(screen.getByPlaceholderText("Search tasks…")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("new-task-agent-button"));
await waitFor(() => expect(screen.getByText("No agents available")).toBeInTheDocument());
fireEvent.click(screen.getByTestId("task-form-inline-workflow"));
await waitFor(() => expect(screen.getByTestId("task-form-more-options")).not.toHaveAttribute("hidden"));
expect(await screen.findByTestId("task-workflow-select")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
expect(screen.getByTestId("task-form-more-options")).toHaveAttribute("hidden");
fireEvent.click(screen.getByTestId("task-form-inline-models"));
await waitFor(() => expect(screen.getByTestId("task-form-more-options")).not.toHaveAttribute("hidden"));
expect(screen.getByText(/Model Configuration/i)).toBeInTheDocument();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
expect(screen.getByTestId("task-form-more-options")).toHaveAttribute("hidden");
fireEvent.click(screen.getByTestId("task-form-inline-node"));
await waitFor(() => expect(screen.getByTestId("task-form-more-options")).not.toHaveAttribute("hidden"));
expect(screen.getByTestId("task-node-select")).toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText("What needs to be done?"), { target: { value: "Promoted controls create task" } });
fireEvent.click(screen.getByRole("button", { name: "Create Task" }));
await waitFor(() => {
expect(props.onCreateTask).toHaveBeenCalledWith(
expect.objectContaining({
executionMode: "fast",
githubTracking: { enabled: true },
}),
);
});
clickSpy.mockRestore();
});
it("omits executionMode from the create payload when Standard is selected", async () => {
const { props } = renderNewTaskModal();

View File

@@ -15,6 +15,9 @@ vi.mock("lucide-react", () => ({
Paperclip: () => null,
Flag: () => null,
Zap: () => null,
Brain: () => null,
Server: () => null,
Cpu: () => null,
}));
// Mock the api module

View File

@@ -450,7 +450,13 @@ describe("core modals mobile css coverage", () => {
const css = loadAllAppCss();
const mobileBlock = getMainMobileBlock(css);
// Verify the quick-fields dep-trigger rule exists with min-height: 36px
// Verify the promoted screenshot action row and quick-fields dep/agent buttons keep the mobile touch target.
const actionButtonMatch = mobileBlock.match(
/\.task-form-description-actions \.btn\s*\{[^}]+\}/,
);
expect(actionButtonMatch).not.toBeNull();
expect(actionButtonMatch![0]).toContain("min-height: 36px");
const quickFieldsTriggerMatch = mobileBlock.match(
/\.new-task-quick-fields \.dep-trigger\s*\{[^}]+\}/,
);