feat(FN-1990): promote quick fields in new task modal

- Add TaskForm renderBelowPrimary and hideDependencies props to support injected primary-section content
- Build NewTaskModal quick-fields for dependency selection and agent assignment above the More options panel
- Style quick-fields and mobile modal spacing with design tokens, including full-width triggers and 36px touch targets
- Expand modal and form tests to cover slot rendering, dependency hiding behavior, quick-fields visibility, and mobile CSS rules
This commit is contained in:
Fusion
2026-04-17 17:44:53 -07:00
committed by gsxdsm
parent a9275f9bdb
commit cfb5228855
6 changed files with 365 additions and 77 deletions

View File

@@ -40,6 +40,12 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const [showAgentPicker, setShowAgentPicker] = useState(false);
const [agentsLoading, setAgentsLoading] = useState(false);
const agentPickerRef = useRef<HTMLDivElement>(null);
// Quick-fields dependency picker state
const [showDeps, setShowDeps] = useState(false);
const [depSearch, setDepSearch] = useState("");
const quickFieldsDepRef = useRef<HTMLDivElement>(null);
const { hasAiProvider, hasGithub, loading: setupReadinessLoading } = useSetupReadiness(projectId);
// Handler for workflow step changes that detects explicit user interaction
@@ -88,6 +94,43 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showAgentPicker]);
// Close quick-fields dep dropdown when clicking outside
useEffect(() => {
if (!showDeps) return;
const handleClickOutside = (e: MouseEvent) => {
if (quickFieldsDepRef.current && !quickFieldsDepRef.current.contains(e.target as Node)) {
setShowDeps(false);
setDepSearch("");
}
};
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [showDeps]);
// Compute available deps for quick-fields picker (same logic as TaskForm)
const availableDeps = tasks
.filter((t) => !dependencies.includes(t.id))
.sort((a, b) => {
const cmp = b.createdAt.localeCompare(a.createdAt);
if (cmp !== 0) return cmp;
const aNum = parseInt(a.id.slice(a.id.lastIndexOf("-") + 1), 10) || 0;
const bNum = parseInt(b.id.slice(b.id.lastIndexOf("-") + 1), 10) || 0;
return bNum - aNum;
});
const filteredDeps = depSearch
? availableDeps.filter((t) =>
t.id.toLowerCase().includes(depSearch.toLowerCase()) ||
(t.title && t.title.toLowerCase().includes(depSearch.toLowerCase())) ||
(t.description && t.description.toLowerCase().includes(depSearch.toLowerCase()))
)
: availableDeps;
const truncate = (s: string, len: number) =>
s.length > len ? s.slice(0, len) + "…" : s;
// Track dirty state
useEffect(() => {
const isDirty =
@@ -208,6 +251,137 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
const selectedAgent = selectedAgentId ? agents.find((agent) => agent.id === selectedAgentId) : undefined;
const selectedAgentLabel = selectedAgent?.name ?? selectedAgentId;
// Quick fields: promoted dependencies and agent assignment
const quickFields = (
<div className="new-task-quick-fields">
{/* Dependencies field */}
<div className="form-group">
<label>Dependencies</label>
<div className="dep-trigger-wrap" ref={quickFieldsDepRef}>
<button
type="button"
className="btn btn-sm dep-trigger"
onClick={() => setShowDeps((v) => !v)}
disabled={isSubmitting}
data-testid="dep-trigger"
>
{dependencies.length > 0 ? `${dependencies.length} selected` : "Add dependencies"}
</button>
{showDeps && (
<div className="dep-dropdown">
<input
className="dep-dropdown-search"
placeholder="Search tasks…"
autoFocus
value={depSearch}
onChange={(e) => setDepSearch(e.target.value)}
onClick={(e) => e.stopPropagation()}
/>
{filteredDeps.length === 0 ? (
<div className="dep-dropdown-empty">No available tasks</div>
) : (
filteredDeps.map((t) => (
<div
key={t.id}
className={`dep-dropdown-item${dependencies.includes(t.id) ? " selected" : ""}`}
onClick={() => {
setDependencies(
dependencies.includes(t.id) ? dependencies.filter((d) => d !== t.id) : [...dependencies, t.id],
);
setShowDeps(false);
setDepSearch("");
}}
onMouseDown={(e) => e.preventDefault()}
>
<span className="dep-dropdown-id">{t.id}</span>
<span className="dep-dropdown-title">{truncate(t.title || t.description || t.id, 30)}</span>
</div>
))
)}
</div>
)}
</div>
{dependencies.length > 0 && (
<div className="selected-deps">
{dependencies.map((depId) => (
<span key={depId} className="dep-chip">
{depId}
<button
type="button"
className="dep-chip-remove"
onClick={() => setDependencies(dependencies.filter((d) => d !== depId))}
disabled={isSubmitting}
>
×
</button>
</span>
))}
</div>
)}
</div>
{/* Agent Assignment */}
<div className="form-group">
<label>Assign Agent</label>
<div className="agent-trigger-wrap" ref={agentPickerRef}>
<button
type="button"
className="btn btn-sm dep-trigger"
onClick={() => {
if (showAgentPicker) {
setShowAgentPicker(false);
} else {
void loadAgents();
}
}}
disabled={isSubmitting}
data-testid="new-task-agent-button"
>
<Bot size={12} style={{ verticalAlign: "middle" }} />
{selectedAgentLabel ? ` ${selectedAgentLabel}` : " Assign agent"}
</button>
{showAgentPicker && (
<div className="dep-dropdown agent-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
<div className="dep-dropdown-search-header">Select agent</div>
{agentsLoading && <div className="dep-dropdown-empty">Loading agents...</div>}
{!agentsLoading && agents.filter((a) => a.state !== "terminated").map((a) => (
<div
key={a.id}
className={`dep-dropdown-item${selectedAgentId === a.id ? " selected" : ""}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setSelectedAgentId(a.id === selectedAgentId ? null : a.id);
setShowAgentPicker(false);
}}
data-testid={`agent-option-${a.id}`}
>
<Bot size={12} style={{ marginRight: 6 }} />
<span className="dep-dropdown-id">{a.role}</span>
<span className="dep-dropdown-title">{a.name}</span>
</div>
))}
{!agentsLoading && agents.filter((a) => a.state !== "terminated").length === 0 && (
<div className="dep-dropdown-empty">No agents available</div>
)}
{selectedAgentId && (
<div
className="dep-dropdown-item"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setSelectedAgentId(null);
setShowAgentPicker(false);
}}
>
<span className="dep-dropdown-title">Clear selection</span>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
if (!isOpen) return null;
return (
@@ -262,67 +436,10 @@ export function NewTaskModal({ isOpen, onClose, projectId, tasks, onCreateTask,
onPlanningModelChange={setPlanningModel}
thinkingLevel={thinkingLevel}
onThinkingLevelChange={setThinkingLevel}
renderBelowPrimary={quickFields}
hideDependencies={true}
/>
{/* Agent Assignment */}
<div className="form-group" style={{ marginTop: "12px" }}>
<label>Assign Agent</label>
<div className="agent-trigger-wrap" ref={agentPickerRef}>
<button
type="button"
className="btn btn-sm dep-trigger"
onClick={() => {
if (showAgentPicker) {
setShowAgentPicker(false);
} else {
void loadAgents();
}
}}
disabled={isSubmitting}
data-testid="new-task-agent-button"
>
<Bot size={12} style={{ verticalAlign: "middle" }} />
{selectedAgentLabel ? ` ${selectedAgentLabel}` : " Assign agent"}
</button>
{showAgentPicker && (
<div className="dep-dropdown agent-picker-dropdown" onMouseDown={(e) => e.preventDefault()}>
<div className="dep-dropdown-search-header">Select agent</div>
{agentsLoading && <div className="dep-dropdown-empty">Loading agents...</div>}
{!agentsLoading && agents.filter((a) => a.state !== "terminated").map((a) => (
<div
key={a.id}
className={`dep-dropdown-item${selectedAgentId === a.id ? " selected" : ""}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setSelectedAgentId(a.id === selectedAgentId ? null : a.id);
setShowAgentPicker(false);
}}
data-testid={`agent-option-${a.id}`}
>
<Bot size={12} style={{ marginRight: 6 }} />
<span className="dep-dropdown-id">{a.role}</span>
<span className="dep-dropdown-title">{a.name}</span>
</div>
))}
{!agentsLoading && agents.filter((a) => a.state !== "terminated").length === 0 && (
<div className="dep-dropdown-empty">No agents available</div>
)}
{selectedAgentId && (
<div
className="dep-dropdown-item"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setSelectedAgentId(null);
setShowAgentPicker(false);
}}
>
<span className="dep-dropdown-title">Clear selection</span>
</div>
)}
</div>
)}
</div>
</div>
</div>
<div className="modal-actions">

View File

@@ -64,6 +64,11 @@ export interface TaskFormProps {
onPlanningMode?: (initialPlan: string) => void;
onSubtaskBreakdown?: (description: string) => void;
onClose?: () => void;
/** Optional content to render between the primary section and the "More options" toggle. */
renderBelowPrimary?: React.ReactNode;
/** When true, skip rendering the Dependencies form-group inside "More options". Use when the parent renders its own dependency UI via renderBelowPrimary. */
hideDependencies?: boolean;
}
export function TaskForm({
@@ -100,9 +105,11 @@ export function TaskForm({
onPlanningMode,
onSubtaskBreakdown,
onClose,
renderBelowPrimary,
hideDependencies,
}: TaskFormProps) {
const hasInitialMoreOptions =
dependencies.length > 0 ||
(hideDependencies ? false : dependencies.length > 0) ||
pendingImages.length > 0 ||
selectedWorkflowSteps.length > 0 ||
presetMode !== "default" ||
@@ -162,7 +169,7 @@ export function TaskForm({
const availablePresets = settings?.modelPresets || [];
const selectedPreset = availablePresets.find((preset) => preset.id === selectedPresetId);
const hasMoreOptionSelections =
dependencies.length > 0 ||
(hideDependencies ? false : dependencies.length > 0) ||
pendingImages.length > 0 ||
selectedWorkflowSteps.length > 0 ||
presetMode !== "default" ||
@@ -678,6 +685,8 @@ export function TaskForm({
)}
</div>
{renderBelowPrimary}
<button
type="button"
className="task-form-more-options-toggle"
@@ -745,6 +754,8 @@ export function TaskForm({
<small>You can also paste images or drag & drop</small>
</div>
{!hideDependencies && (
<>
{/* Dependencies */}
<div className="form-group">
<label>Dependencies</label>
@@ -803,6 +814,8 @@ export function TaskForm({
</div>
)}
</div>
</>
)}
{/* Model Selection */}
<div className="form-group">

View File

@@ -71,7 +71,7 @@ describe("NewTaskModal", () => {
vi.clearAllMocks();
});
it("renders all form fields when open", () => {
it("renders all form fields when open", async () => {
renderNewTaskModal();
expect(screen.getByText("New Task")).toBeTruthy();
@@ -79,38 +79,62 @@ describe("NewTaskModal", () => {
expect(screen.getByRole("button", { name: "Plan" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Subtask" })).toBeTruthy();
// Dependencies and agent are in quick-fields — visible by default (no toggle needed)
expect(screen.getByTestId("dep-trigger")).toBeInTheDocument();
expect(screen.getByTestId("new-task-agent-button")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("task-form-more-options-toggle"));
expect(screen.getByRole("button", { name: "Add dependencies" })).toBeTruthy();
expect(screen.getByText(/Model Configuration/i)).toBeTruthy();
expect(screen.getByText(/Attachments/i)).toBeTruthy();
await waitFor(() => {
expect(screen.getByText(/Model Configuration/i)).toBeTruthy();
expect(screen.getByText(/Attachments/i)).toBeTruthy();
});
expect(screen.getByRole("button", { name: "Create Task" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Cancel" })).toBeTruthy();
});
it("shows More options toggle and reveals advanced fields when clicked", () => {
it("shows More options toggle and reveals advanced fields when clicked", async () => {
renderNewTaskModal();
const toggle = screen.getByTestId("task-form-more-options-toggle");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByRole("button", { name: "Add dependencies" })).toBeNull();
// Dependencies are now in quick-fields (visible by default), so the dep-trigger is present
expect(screen.getByTestId("dep-trigger")).toBeInTheDocument();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByRole("button", { name: "Add dependencies" })).toBeTruthy();
await waitFor(() => {
expect(toggle).toHaveAttribute("aria-expanded", "true");
});
// Model Configuration, Attachments, and Workflow Steps are revealed
expect(screen.getByText(/Model Configuration/i)).toBeTruthy();
expect(screen.getByText(/Attachments/i)).toBeTruthy();
expect(screen.getByText(/Workflow Steps/i)).toBeTruthy();
});
it("renders attachments before dependencies in form order", () => {
it("shows dependencies and agent picker by default without expanding More options", () => {
renderNewTaskModal();
const attachmentsLabel = screen.getByText("Attachments");
const dependenciesLabel = screen.getByText("Dependencies");
// Both dep-trigger and agent button should be visible by default
expect(screen.getByTestId("dep-trigger")).toBeInTheDocument();
expect(screen.getByTestId("new-task-agent-button")).toBeInTheDocument();
// More options should be collapsed
expect(screen.getByTestId("task-form-more-options-toggle")).toHaveAttribute("aria-expanded", "false");
});
it("renders dependencies before attachments in form order (quick-fields before More options)", () => {
renderNewTaskModal();
const dependenciesLabel = screen.getByText("Dependencies");
// Attachments is inside the collapsed "More options" section, so we need to expand first
const toggle = screen.getByTestId("task-form-more-options-toggle");
fireEvent.click(toggle);
const attachmentsLabel = screen.getByText("Attachments");
// Dependencies (in quick-fields) appears before Attachments (in More options)
expect(
attachmentsLabel.compareDocumentPosition(dependenciesLabel) & Node.DOCUMENT_POSITION_FOLLOWING,
dependenciesLabel.compareDocumentPosition(attachmentsLabel) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});

View File

@@ -1254,4 +1254,63 @@ describe("TaskForm focus behavior (FN-1459)", () => {
expect(document.activeElement).not.toBe(textarea);
});
});
// renderBelowPrimary and hideDependencies slot tests
describe("renderBelowPrimary and hideDependencies", () => {
it("renders renderBelowPrimary content between primary section and More options toggle", () => {
renderTaskForm({
renderBelowPrimary: <div data-testid="injected">Custom content</div>,
});
const injected = screen.getByTestId("injected");
const toggle = screen.getByTestId("task-form-more-options-toggle");
const descriptionArea = screen.getByRole("textbox", { name: /Description/i });
expect(injected).toBeInTheDocument();
// Injected element should appear AFTER the primary section (description area)
expect(
descriptionArea.compareDocumentPosition(injected) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
// Injected element should appear BEFORE the more-options toggle
expect(
injected.compareDocumentPosition(toggle) & Node.DOCUMENT_POSITION_FOLLOWING,
).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});
it("hides dependencies section when hideDependencies is true", async () => {
renderTaskForm({ hideDependencies: true });
// Expand "More options"
const toggle = screen.getByTestId("task-form-more-options-toggle");
fireEvent.click(toggle);
await waitFor(() => {
expect(toggle).toHaveAttribute("aria-expanded", "true");
});
// Dependencies label and dep-trigger should not be in the document
expect(screen.queryByText("Dependencies")).toBeNull();
expect(screen.queryByRole("button", { name: /Add dependencies/i })).toBeNull();
expect(screen.queryByText(/selected/i)).toBeNull();
});
it("does not auto-expand More options for dependency selections when hideDependencies is true", async () => {
renderTaskForm({
hideDependencies: true,
dependencies: ["FN-001"],
});
const toggle = screen.getByTestId("task-form-more-options-toggle");
await waitFor(() => {
expect(toggle).toHaveAttribute("aria-expanded", "false");
});
});
it("renders nothing when renderBelowPrimary is not provided", () => {
renderTaskForm({});
// Should not have any unexpected elements - just verify normal rendering works
expect(screen.getByRole("textbox", { name: /Description/i })).toBeInTheDocument();
expect(screen.getByTestId("task-form-more-options-toggle")).toBeInTheDocument();
});
});
});

View File

@@ -167,4 +167,43 @@ describe("core modals mobile css coverage", () => {
expect(fullscreenBlockMatch).not.toBeNull();
expect(fullscreenBlockMatch![0]).toContain("max-height: unset");
});
it("NewTaskModal: quick fields buttons meet 36px touch target on mobile", () => {
const css = fs.readFileSync(stylesPath, "utf-8");
const mobileBlock = getMainMobileBlock(css);
// Verify the quick-fields dep-trigger rule exists with min-height: 36px
const quickFieldsTriggerMatch = mobileBlock.match(
/\.new-task-quick-fields \.dep-trigger\s*\{[^}]+\}/,
);
expect(quickFieldsTriggerMatch).not.toBeNull();
expect(quickFieldsTriggerMatch![0]).toContain("min-height: 36px");
});
it("NewTaskModal: modal body uses token-based padding on mobile", () => {
const css = fs.readFileSync(stylesPath, "utf-8");
const mobileBlock = getMainMobileBlock(css);
// Extract the new-task-modal .modal-body rule
const modalBodyMatch = mobileBlock.match(
/\.new-task-modal \.modal-body\s*\{[^}]+\}/,
);
expect(modalBodyMatch).not.toBeNull();
// Should use var(--space-sm) for horizontal padding (not hardcoded 0)
expect(modalBodyMatch![0]).toContain("var(--space-sm)");
expect(modalBodyMatch![0]).toContain("var(--space-md)");
});
it("NewTaskModal: more options toggle uses token-based margin on mobile", () => {
const css = fs.readFileSync(stylesPath, "utf-8");
const mobileBlock = getMainMobileBlock(css);
// Extract the more-options-toggle rule
const toggleMatch = mobileBlock.match(
/\.task-form-more-options-toggle\s*\{[^}]+\}/,
);
expect(toggleMatch).not.toBeNull();
// Should use var(--space-md) for horizontal margin (not hardcoded 14px)
expect(toggleMatch![0]).toContain("var(--space-md)");
});
});