feat(FN-781): reposition AI-assisted creation controls adjacent to description field

- Move Plan and Subtask buttons next to the description input in QuickEntryBox, InlineCreateCard, and TaskForm
- Improve action button layout with consistent icon+label pattern and disabled states
- Add CSS styles for new action button group and description-adjacent controls
- Add regression tests for all three task creation surfaces ensuring buttons appear next to description
- Document the description-adjacent AI controls pattern in dashboard README
This commit is contained in:
gsxdsm
2026-04-03 11:58:49 -07:00
parent 152c3fd826
commit 881674b19f
8 changed files with 398 additions and 171 deletions

View File

@@ -44,6 +44,7 @@ AI-guided interactive planning for creating well-specified tasks from high-level
- **Task Detail Editing**: Edit task title and description directly in the task detail modal. Click the pencil icon in the modal header (available for Triage and Todo tasks) to enter edit mode.
- **List View**: Alternative tabular view for tasks with sorting and filtering. The "Hide Done" toggle hides both Done and Archived tasks for an active-work-only view.
- **Model Selection at Creation**: Choose executor and validator AI models while creating tasks from the board or list view, or leave them unset to use the global defaults. Quick-add model dropdowns in both the board triage column and the list view honor saved favorite providers and pinned models, matching the rest of the dashboard model UI.
- **AI-Assisted Creation Controls**: Plan, Subtask, and Refine buttons appear directly below the description textarea in all task creation surfaces (quick entry box, inline create card, and task form modal). These description-adjacent controls make AI-assisted creation and refinement feel directly associated with the text being edited. Deps, Models, and Save actions remain in the expanded controls footer.
- **Layered Model Dropdowns**: Shared model combobox menus render in a top-level portal attached to `document.body`, so they stay above board columns and scrollable modal content instead of being clipped behind surrounding dashboard surfaces.
- **Bulk Model Editing**: Update AI model configuration for multiple tasks at once in the list view. Select tasks via checkboxes (archived tasks excluded), then use the "Bulk Edit Models" toolbar to apply executor and/or validator model changes to all selected tasks. Selection persists in localStorage across page reloads.
- **Task Details**: View full task specifications, agent logs, and attachments. The Agent Log tab header shows the effective executor and validator model names resolved from task-level overrides or project/global settings fallbacks, matching the same resolution order the engine uses at runtime.

View File

@@ -544,6 +544,33 @@ export function InlineCreateCard({
{isExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
</div>
{/* AI-assisted refinement actions — always visible when expanded */}
{isExpanded && !submitting && (
<div className="inline-create-description-actions" data-testid="inline-create-description-actions">
<button
type="button"
className="btn btn-sm"
onClick={handlePlanClick}
disabled={!description.trim()}
data-testid="plan-button"
title="Open planning mode with current description"
>
<Lightbulb size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
Plan
</button>
<button
type="button"
className="btn btn-sm"
onClick={handleSubtaskClick}
disabled={!description.trim()}
data-testid="subtask-button"
title="Break down into AI-generated subtasks"
>
<ListTree size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
Subtask
</button>
</div>
)}
{pendingImages.length > 0 && (
<div className="inline-create-previews">
{pendingImages.map((img, i) => (
@@ -703,41 +730,17 @@ export function InlineCreateCard({
</div>
{!submitting && (
<>
<button
type="button"
className="btn btn-sm"
onClick={handlePlanClick}
disabled={!description.trim()}
data-testid="plan-button"
title="Open planning mode with current description"
>
<Lightbulb size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
Plan
</button>
<button
type="button"
className="btn btn-sm"
onClick={handleSubtaskClick}
disabled={!description.trim()}
data-testid="subtask-button"
title="Break down into AI-generated subtasks"
>
<ListTree size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
Subtask
</button>
<button
type="button"
className={`btn btn-sm ${browserVerification ? "btn-active" : ""}`}
onClick={() => setBrowserVerification((v) => !v)}
disabled={submitting}
data-testid="browser-verification-toggle"
title="Verify with agent-browser"
>
<Globe size={12} style={{ verticalAlign: "middle" }} />
Browser
</button>
</>
<button
type="button"
className={`btn btn-sm ${browserVerification ? "btn-active" : ""}`}
onClick={() => setBrowserVerification((v) => !v)}
disabled={submitting}
data-testid="browser-verification-toggle"
title="Verify with agent-browser"
>
<Globe size={12} style={{ verticalAlign: "middle" }} />
Browser
</button>
)}
</div>
<div className="inline-create-actions">

View File

@@ -569,6 +569,87 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
{isDisclosureExpanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
</div>
{/* AI-assisted refinement actions — always visible when expanded */}
{isExpanded && !isSubmitting && (
<div className="quick-entry-description-actions" data-testid="quick-entry-description-actions">
<button
type="button"
className="btn btn-sm"
onClick={handlePlanClick}
onMouseDown={(e) => e.preventDefault()}
disabled={!description.trim()}
data-testid="plan-button"
title="Open planning mode with current description"
>
<Lightbulb size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
Plan
</button>
<button
type="button"
className="btn btn-sm"
onClick={handleSubtaskClick}
onMouseDown={(e) => e.preventDefault()}
disabled={!description.trim()}
data-testid="subtask-button"
title="Break down into AI-generated subtasks"
>
<ListTree size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
Subtask
</button>
<div className="refine-trigger-wrap" ref={refineMenuRef}>
<button
type="button"
className={`btn btn-sm refine-button ${isRefining ? "refine-button--loading" : ""}`}
onClick={() => setIsRefineMenuOpen((prev) => !prev)}
disabled={!description.trim() || isRefining}
data-testid="refine-button"
title="Refine description with AI"
>
<Sparkles size={12} style={{ verticalAlign: "middle" }} />
{isRefining ? "Refining..." : "Refine"}
</button>
{isRefineMenuOpen && (
<div
className="refine-menu"
onMouseDown={(e) => e.preventDefault()}
>
<div
className="refine-menu-item"
onClick={() => handleRefine("clarify")}
data-testid="refine-clarify"
>
<div className="refine-menu-item-title">Clarify</div>
<div className="refine-menu-item-desc">Make the description clearer and more specific</div>
</div>
<div
className="refine-menu-item"
onClick={() => handleRefine("add-details")}
data-testid="refine-add-details"
>
<div className="refine-menu-item-title">Add details</div>
<div className="refine-menu-item-desc">Add implementation details and context</div>
</div>
<div
className="refine-menu-item"
onClick={() => handleRefine("expand")}
data-testid="refine-expand"
>
<div className="refine-menu-item-title">Expand</div>
<div className="refine-menu-item-desc">Expand into a more comprehensive description</div>
</div>
<div
className="refine-menu-item"
onClick={() => handleRefine("simplify")}
data-testid="refine-simplify"
>
<div className="refine-menu-item-title">Simplify</div>
<div className="refine-menu-item-desc">Simplify and make more concise</div>
</div>
</div>
)}
</div>
</div>
)}
<div
id="quick-entry-controls"
className="quick-entry-controls"
@@ -649,96 +730,18 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
</div>
{!isSubmitting && (
<>
<button
type="button"
className="btn btn-sm"
onClick={handlePlanClick}
onMouseDown={(e) => e.preventDefault()}
disabled={!description.trim()}
data-testid="plan-button"
title="Open planning mode with current description"
>
<Lightbulb size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
Plan
</button>
<button
type="button"
className="btn btn-sm"
onClick={handleSubtaskClick}
onMouseDown={(e) => e.preventDefault()}
disabled={!description.trim()}
data-testid="subtask-button"
title="Break down into AI-generated subtasks"
>
<ListTree size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
Subtask
</button>
<button
type="button"
className="btn btn-sm"
onClick={handleSaveClick}
onMouseDown={(e) => e.preventDefault()}
disabled={!description.trim() || isSubmitting}
data-testid="save-button"
title="Create task"
>
<Save size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
Save
</button>
<div className="refine-trigger-wrap" ref={refineMenuRef}>
<button
type="button"
className={`btn btn-sm refine-button ${isRefining ? "refine-button--loading" : ""}`}
onClick={() => setIsRefineMenuOpen((prev) => !prev)}
disabled={!description.trim() || isRefining}
data-testid="refine-button"
title="Refine description with AI"
>
<Sparkles size={12} style={{ verticalAlign: "middle" }} />
{isRefining ? "Refining..." : "Refine"}
</button>
{isRefineMenuOpen && (
<div
className="refine-menu"
onMouseDown={(e) => e.preventDefault()}
>
<div
className="refine-menu-item"
onClick={() => handleRefine("clarify")}
data-testid="refine-clarify"
>
<div className="refine-menu-item-title">Clarify</div>
<div className="refine-menu-item-desc">Make the description clearer and more specific</div>
</div>
<div
className="refine-menu-item"
onClick={() => handleRefine("add-details")}
data-testid="refine-add-details"
>
<div className="refine-menu-item-title">Add details</div>
<div className="refine-menu-item-desc">Add implementation details and context</div>
</div>
<div
className="refine-menu-item"
onClick={() => handleRefine("expand")}
data-testid="refine-expand"
>
<div className="refine-menu-item-title">Expand</div>
<div className="refine-menu-item-desc">Expand into a more comprehensive description</div>
</div>
<div
className="refine-menu-item"
onClick={() => handleRefine("simplify")}
data-testid="refine-simplify"
>
<div className="refine-menu-item-title">Simplify</div>
<div className="refine-menu-item-desc">Simplify and make more concise</div>
</div>
</div>
)}
</div>
</>
<button
type="button"
className="btn btn-sm"
onClick={handleSaveClick}
onMouseDown={(e) => e.preventDefault()}
disabled={!description.trim() || isSubmitting}
data-testid="save-button"
title="Create task"
>
<Save size={12} style={{ verticalAlign: "middle", marginRight: 4 }} />
Save
</button>
)}
</div>
<div className="quick-entry-hint">

View File

@@ -387,6 +387,50 @@ export function TaskForm({
</div>
</div>
{/* AI-assisted creation actions — adjacent to description (create mode only) */}
{mode === "create" && (onPlanningMode || onSubtaskBreakdown) && (
<div className="task-form-description-actions" data-testid="task-form-description-actions">
{onPlanningMode && (
<button
type="button"
className="btn btn-sm"
onClick={() => {
const trimmed = description.trim();
if (!trimmed) {
addToast("Enter a description first", "error");
return;
}
onClose?.();
onPlanningMode(trimmed);
}}
disabled={disabled || !description.trim()}
data-testid="task-form-plan-button"
>
Plan
</button>
)}
{onSubtaskBreakdown && (
<button
type="button"
className="btn btn-sm"
onClick={() => {
const trimmed = description.trim();
if (!trimmed) {
addToast("Enter a description first", "error");
return;
}
onClose?.();
onSubtaskBreakdown(trimmed);
}}
disabled={disabled || !description.trim()}
data-testid="task-form-subtask-button"
>
Subtask
</button>
)}
</div>
)}
{/* Dependencies */}
<div className="form-group">
<label>Dependencies</label>
@@ -613,52 +657,6 @@ export function TaskForm({
</div>
</div>
{/* AI-assisted creation (create mode only) */}
{mode === "create" && (onPlanningMode || onSubtaskBreakdown) && (
<div className="form-group">
<label>AI-assisted creation</label>
<div style={{ display: "flex", gap: "8px", flexWrap: "wrap" }}>
{onPlanningMode && (
<button
type="button"
className="btn btn-sm"
onClick={() => {
const trimmed = description.trim();
if (!trimmed) {
addToast("Enter a description first", "error");
return;
}
onClose?.();
onPlanningMode(trimmed);
}}
disabled={disabled || !description.trim()}
>
Plan
</button>
)}
{onSubtaskBreakdown && (
<button
type="button"
className="btn btn-sm"
onClick={() => {
const trimmed = description.trim();
if (!trimmed) {
addToast("Enter a description first", "error");
return;
}
onClose?.();
onSubtaskBreakdown(trimmed);
}}
disabled={disabled || !description.trim()}
>
Subtask
</button>
)}
</div>
<small>Use Plan for clarifying questions or Subtask to split the work into editable child tasks.</small>
</div>
)}
{/* Attachments */}
<div className="form-group">
<label>Attachments</label>

View File

@@ -875,4 +875,65 @@ describe("InlineCreateCard button visibility when collapsed", () => {
expect(screen.getByTestId("browser-verification-toggle")).not.toHaveClass("btn-active");
});
});
describe("Description-adjacent actions layout (FN-781)", () => {
it("renders Plan and Subtask in description-actions area when expanded", () => {
renderCard();
expandCard();
// The description-actions container should exist
expect(screen.getByTestId("inline-create-description-actions")).toBeTruthy();
// Plan and Subtask buttons should be inside it
const actionsContainer = screen.getByTestId("inline-create-description-actions");
expect(actionsContainer.contains(screen.getByTestId("plan-button"))).toBe(true);
expect(actionsContainer.contains(screen.getByTestId("subtask-button"))).toBe(true);
});
it("does not render description-actions when not expanded", () => {
renderCard();
// Description actions should not exist when collapsed
expect(screen.queryByTestId("inline-create-description-actions")).toBeNull();
});
it("Save button remains in footer area, not description-actions", () => {
renderCard();
expandCard();
const actionsContainer = screen.getByTestId("inline-create-description-actions");
const saveButton = screen.getByTestId("save-button");
// Save button should NOT be in the description-actions area
expect(actionsContainer.contains(saveButton)).toBe(false);
});
it("Deps, Preset, Models buttons remain in footer area, not description-actions", () => {
renderCard();
expandCard();
const actionsContainer = screen.getByTestId("inline-create-description-actions");
const depsButton = screen.getByText(/Deps/);
const modelsButton = screen.getByRole("button", { name: /Models/i });
// Deps and Models should NOT be in the description-actions area
expect(actionsContainer.contains(depsButton)).toBe(false);
expect(actionsContainer.contains(modelsButton)).toBe(false);
});
it("Plan and Subtask disabled state still works in description-actions", () => {
renderCard();
expandCard();
const textarea = screen.getByPlaceholderText("What needs to be done?");
// Buttons should be disabled when description is empty
expect((screen.getByTestId("plan-button") as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByTestId("subtask-button") as HTMLButtonElement).disabled).toBe(true);
// Type something — buttons should become enabled
fireEvent.change(textarea, { target: { value: "Some task" } });
expect((screen.getByTestId("plan-button") as HTMLButtonElement).disabled).toBe(false);
expect((screen.getByTestId("subtask-button") as HTMLButtonElement).disabled).toBe(false);
});
});
});

View File

@@ -704,6 +704,7 @@ describe("QuickEntryBox", () => {
it("calls onPlanningMode and clears input when Plan clicked", async () => {
const onPlanningMode = vi.fn();
const { props } = renderQuickEntryBox({ onPlanningMode });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Plan this task" } });
@@ -720,6 +721,7 @@ describe("QuickEntryBox", () => {
it("calls onSubtaskBreakdown and clears input when Subtask clicked", async () => {
const onSubtaskBreakdown = vi.fn();
const { props } = renderQuickEntryBox({ onSubtaskBreakdown });
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Break this down" } });
@@ -1578,4 +1580,64 @@ describe("QuickEntryBox", () => {
expect(screen.getByTestId("refine-button")).toBeTruthy();
});
});
describe("Description-adjacent actions layout (FN-781)", () => {
it("renders Plan, Subtask, and Refine in description-actions area when expanded", () => {
renderQuickEntryBox({});
expandQuickEntry();
// The description-actions container should exist
expect(screen.getByTestId("quick-entry-description-actions")).toBeTruthy();
// Plan, Subtask, and Refine buttons should be inside it
const actionsContainer = screen.getByTestId("quick-entry-description-actions");
expect(actionsContainer.contains(screen.getByTestId("plan-button"))).toBe(true);
expect(actionsContainer.contains(screen.getByTestId("subtask-button"))).toBe(true);
expect(actionsContainer.contains(screen.getByTestId("refine-button"))).toBe(true);
});
it("does not render description-actions when not expanded", () => {
renderQuickEntryBox({});
// Description actions should not exist when collapsed
expect(screen.queryByTestId("quick-entry-description-actions")).toBeNull();
});
it("Save button remains in the expanded controls area, not description-actions", () => {
renderQuickEntryBox({});
expandQuickEntry();
const actionsContainer = screen.getByTestId("quick-entry-description-actions");
const saveButton = screen.getByTestId("save-button");
// Save button should NOT be in the description-actions area
expect(actionsContainer.contains(saveButton)).toBe(false);
});
it("Deps and Models buttons remain in the expanded controls area, not description-actions", () => {
renderQuickEntryBox({});
expandQuickEntry();
const actionsContainer = screen.getByTestId("quick-entry-description-actions");
const depsButton = screen.getByTestId("quick-entry-deps-button");
const modelsButton = screen.getByTestId("quick-entry-models-button");
// Deps and Models should NOT be in the description-actions area
expect(actionsContainer.contains(depsButton)).toBe(false);
expect(actionsContainer.contains(modelsButton)).toBe(false);
});
it("Plan button disabled state still works when in description-actions", () => {
renderQuickEntryBox({});
expandQuickEntry();
const textarea = screen.getByTestId("quick-entry-input");
// Plan button should be disabled when description is empty
expect((screen.getByTestId("plan-button") as HTMLButtonElement).disabled).toBe(true);
// Type something — Plan should become enabled
fireEvent.change(textarea, { target: { value: "Some task" } });
expect((screen.getByTestId("plan-button") as HTMLButtonElement).disabled).toBe(false);
});
});
});

View File

@@ -249,3 +249,75 @@ describe("TaskForm", () => {
});
});
});
describe("TaskForm description-adjacent actions layout (FN-781)", () => {
it("renders Plan and Subtask in description-actions area in create mode", () => {
renderTaskForm({
mode: "create",
description: "Some task",
onPlanningMode: vi.fn(),
onSubtaskBreakdown: vi.fn(),
});
// The description-actions container should exist
expect(screen.getByTestId("task-form-description-actions")).toBeTruthy();
// Plan and Subtask buttons should be inside it
const actionsContainer = screen.getByTestId("task-form-description-actions");
expect(actionsContainer.contains(screen.getByTestId("task-form-plan-button"))).toBe(true);
expect(actionsContainer.contains(screen.getByTestId("task-form-subtask-button"))).toBe(true);
});
it("does not render description-actions in edit mode", () => {
renderTaskForm({
mode: "edit",
title: "My task",
onTitleChange: vi.fn(),
description: "Some task",
onPlanningMode: vi.fn(),
onSubtaskBreakdown: vi.fn(),
});
expect(screen.queryByTestId("task-form-description-actions")).toBeNull();
});
it("Plan and Subtask buttons are disabled when description is empty", () => {
renderTaskForm({
mode: "create",
description: "",
onPlanningMode: vi.fn(),
onSubtaskBreakdown: vi.fn(),
});
expect((screen.getByTestId("task-form-plan-button") as HTMLButtonElement).disabled).toBe(true);
expect((screen.getByTestId("task-form-subtask-button") as HTMLButtonElement).disabled).toBe(true);
});
it("Plan and Subtask buttons are enabled when description has content", () => {
renderTaskForm({
mode: "create",
description: "A real task",
onPlanningMode: vi.fn(),
onSubtaskBreakdown: vi.fn(),
});
expect((screen.getByTestId("task-form-plan-button") as HTMLButtonElement).disabled).toBe(false);
expect((screen.getByTestId("task-form-subtask-button") as HTMLButtonElement).disabled).toBe(false);
});
it("Refine button remains near the description textarea", () => {
renderTaskForm({
mode: "create",
description: "Some text",
onPlanningMode: vi.fn(),
onSubtaskBreakdown: vi.fn(),
});
// Refine button should be rendered (it's inside the description-with-refine wrapper)
expect(screen.getByTestId("refine-button")).toBeTruthy();
// But NOT inside the description-actions container
const actionsContainer = screen.getByTestId("task-form-description-actions");
expect(actionsContainer.contains(screen.getByTestId("refine-button"))).toBe(false);
});
});

View File

@@ -4348,6 +4348,14 @@ body {
flex-wrap: wrap;
}
/* AI-assisted description actions — sits between textarea and expanded controls */
.inline-create-description-actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.inline-create-controls {
display: flex;
align-items: center;
@@ -4576,6 +4584,16 @@ body {
position: relative;
}
/* AI-assisted description actions — sits directly below description in TaskForm */
.task-form-description-actions {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
margin-bottom: 4px;
flex-wrap: wrap;
}
.description-with-refine .refine-button {
position: absolute;
top: 8px;
@@ -10971,6 +10989,15 @@ html .column.drag-over * {
flex-wrap: wrap;
}
/* AI-assisted description actions — sits between textarea and expanded controls */
.quick-entry-description-actions {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
flex-wrap: wrap;
}
.quick-entry-controls-left {
display: flex;
align-items: center;