feat(FN-888): implement actions dropdown in QuickEntryBox

- Refactor QuickEntryBox to use an actions dropdown (⋯) for advanced controls (deps, models, save)
- Move dependency picker and model selection (plan/executor/validator) into nested dropdown menus
- Add Plan (lightbulb) and Subtask (list) quick-action buttons to the input bar
- Update ListView and QuickEntryBox tests for new dropdown-driven UI
- Add CSS styles for dropdown menus, nested submenus, and action buttons
- Update AGENTS.md and README.md docs for the new QuickEntryBox layout
This commit is contained in:
gsxdsm
2026-04-04 23:48:00 -07:00
parent bf1cb5df5c
commit a2d993586f
6 changed files with 625 additions and 207 deletions

View File

@@ -92,6 +92,7 @@ vi.mock("lucide-react", () => ({
ChevronDown: () => null,
ChevronUp: () => null,
ChevronRight: () => null,
MoreHorizontal: () => null,
}));
// Mock ModelSelectionModal (kept for backward compatibility - no longer directly rendered)
@@ -160,6 +161,12 @@ function expandQuickEntry() {
fireEvent.click(toggleButton);
}
// Helper to open the actions dropdown (which contains Deps/Models/Save)
function openActionsDropdown() {
const actionsButton = screen.getByTestId("quick-entry-actions-button");
fireEvent.click(actionsButton);
}
describe("QuickEntryBox", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
@@ -500,7 +507,7 @@ describe("QuickEntryBox", () => {
});
describe("Rich creation features", () => {
it("shows dependency button when expanded", () => {
it("shows dependency button when expanded and actions dropdown is open", () => {
renderQuickEntryBox({});
// Initially, controls region is collapsed/hidden
@@ -511,11 +518,14 @@ describe("QuickEntryBox", () => {
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with deps" } });
// Open the actions dropdown to access Deps/Models/Save
openActionsDropdown();
// Now the dependency button should be visible
expect(screen.getByTestId("quick-entry-deps-button")).toBeTruthy();
});
it("shows model selector button when expanded", () => {
it("shows model selector button when expanded and actions dropdown is open", () => {
renderQuickEntryBox({});
// Initially, controls region is collapsed/hidden
@@ -526,6 +536,9 @@ describe("QuickEntryBox", () => {
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with models" } });
// Open the actions dropdown to access Deps/Models/Save
openActionsDropdown();
// Now the model selector button should be visible
expect(screen.getByTestId("quick-entry-models-button")).toBeTruthy();
});
@@ -549,6 +562,7 @@ describe("QuickEntryBox", () => {
it("opens dependency dropdown when clicking deps button", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with deps" } });
@@ -562,6 +576,7 @@ describe("QuickEntryBox", () => {
it("opens model menu when clicking models button", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with models" } });
@@ -579,6 +594,7 @@ describe("QuickEntryBox", () => {
it("shows Plan, Executor, and Validator options in model menu", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with models" } });
@@ -592,6 +608,7 @@ describe("QuickEntryBox", () => {
it("clicking Executor opens submenu with CustomModelDropdown", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with models" } });
@@ -607,6 +624,7 @@ describe("QuickEntryBox", () => {
it("clicking Plan opens submenu with CustomModelDropdown", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with models" } });
@@ -619,6 +637,7 @@ describe("QuickEntryBox", () => {
it("clicking Validator opens submenu with CustomModelDropdown", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with models" } });
@@ -631,6 +650,7 @@ describe("QuickEntryBox", () => {
it("back button returns to top-level model menu", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with models" } });
@@ -649,6 +669,7 @@ describe("QuickEntryBox", () => {
it("Escape from submenu returns to top-level menu without closing it", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with models" } });
@@ -672,6 +693,7 @@ describe("QuickEntryBox", () => {
it("selecting Plan model updates the Plan menu item value", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with models" } });
@@ -695,6 +717,7 @@ describe("QuickEntryBox", () => {
it("selecting Validator model updates the Validator menu item value", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with models" } });
@@ -716,6 +739,7 @@ describe("QuickEntryBox", () => {
it("clearing Plan model returns menu item to default state", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with models" } });
@@ -738,6 +762,7 @@ describe("QuickEntryBox", () => {
it("selects dependencies and includes them in submit payload", async () => {
const { props } = renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with deps" } });
@@ -880,6 +905,7 @@ describe("QuickEntryBox", () => {
it("includes selected models in submit payload", async () => {
const { props } = renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with model" } });
@@ -914,6 +940,7 @@ describe("QuickEntryBox", () => {
it("closes model menu on Escape when open", async () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with menu" } });
@@ -1211,6 +1238,7 @@ describe("QuickEntryBox", () => {
it("does not clear localStorage on first Escape when closing dropdowns", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
// Type something and open dropdown
@@ -1465,7 +1493,7 @@ describe("QuickEntryBox", () => {
});
describe("Save button", () => {
it("shows save button when expanded and text is entered", () => {
it("shows save button when expanded and actions dropdown is open", () => {
renderQuickEntryBox({});
// Initially, controls region is collapsed/hidden
@@ -1476,6 +1504,9 @@ describe("QuickEntryBox", () => {
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task to save" } });
// Open actions dropdown to access Save
openActionsDropdown();
// Now the save button should be visible
expect(screen.getByTestId("save-button")).toBeTruthy();
});
@@ -1483,6 +1514,7 @@ describe("QuickEntryBox", () => {
it("save button is disabled when textarea is empty", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
// Type something
@@ -1505,6 +1537,7 @@ describe("QuickEntryBox", () => {
props.onCreate.mockImplementation(() => new Promise((resolve) => setTimeout(resolve, 100)));
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "New task" } });
@@ -1524,6 +1557,7 @@ describe("QuickEntryBox", () => {
it("clicking save button persists to localStorage", async () => {
const { props } = renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Draft task description" } });
@@ -1546,6 +1580,7 @@ describe("QuickEntryBox", () => {
it("clicking save button creates the task", async () => {
const { props } = renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task to save" } });
@@ -1567,6 +1602,7 @@ describe("QuickEntryBox", () => {
it("save button has correct test id", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task to save" } });
@@ -1581,6 +1617,7 @@ describe("QuickEntryBox", () => {
it("save button has correct title attribute", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task to save" } });
@@ -1592,6 +1629,7 @@ describe("QuickEntryBox", () => {
it("save button prevents textarea blur on mousedown", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task to save" } });
@@ -1638,9 +1676,9 @@ describe("QuickEntryBox", () => {
// Now controls should be visible
expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(false);
// And buttons inside should be accessible
expect(screen.getByTestId("quick-entry-deps-button")).toBeTruthy();
expect(screen.getByTestId("quick-entry-models-button")).toBeTruthy();
// Actions button should be accessible (Deps/Models/Save are inside it)
expect(screen.getByTestId("quick-entry-actions-button")).toBeTruthy();
// Plan/Subtask are always visible in description-actions when expanded
expect(screen.getByTestId("plan-button")).toBeTruthy();
expect(screen.getByTestId("subtask-button")).toBeTruthy();
});
@@ -1651,7 +1689,7 @@ describe("QuickEntryBox", () => {
// Expand
expandQuickEntry();
expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(false);
expect(screen.getByTestId("quick-entry-deps-button")).toBeTruthy();
expect(screen.getByTestId("quick-entry-actions-button")).toBeTruthy();
// Collapse
expandQuickEntry();
@@ -1660,11 +1698,12 @@ describe("QuickEntryBox", () => {
expect(document.getElementById("quick-entry-controls")?.hasAttribute("hidden")).toBe(true);
});
it("verifies all buttons are accessible when expanded", () => {
it("verifies all buttons are accessible when expanded and actions dropdown is open", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
// All buttons should be accessible when expanded
// All buttons should be accessible when expanded + actions dropdown open
expect(screen.getByTestId("quick-entry-deps-button")).toBeTruthy();
expect(screen.getByTestId("quick-entry-models-button")).toBeTruthy();
expect(screen.getByTestId("plan-button")).toBeTruthy();
@@ -1696,26 +1735,28 @@ describe("QuickEntryBox", () => {
expect(screen.queryByTestId("quick-entry-description-actions")).toBeNull();
});
it("Save button remains in the expanded controls area, not description-actions", () => {
it("Save button is in the actions dropdown, not description-actions", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const actionsContainer = screen.getByTestId("quick-entry-description-actions");
const saveButton = screen.getByTestId("save-button");
// Save button should NOT be in the description-actions area
// Save button should NOT be in the description-actions area (it's in the actions dropdown)
expect(actionsContainer.contains(saveButton)).toBe(false);
});
it("Deps and Models buttons remain in the expanded controls area, not description-actions", () => {
it("Deps and Models buttons are in the actions dropdown, not description-actions", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
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
// Deps and Models should NOT be in the description-actions area (they're in the actions dropdown)
expect(actionsContainer.contains(depsButton)).toBe(false);
expect(actionsContainer.contains(modelsButton)).toBe(false);
});
@@ -1753,6 +1794,7 @@ describe("QuickEntryBox", () => {
// Don't pass availableModels so component fetches settings itself
renderQuickEntryBox({ availableModels: undefined });
expandQuickEntry();
openActionsDropdown();
// Open model menu
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
@@ -1785,6 +1827,7 @@ describe("QuickEntryBox", () => {
const onCreate = vi.fn().mockResolvedValue(undefined);
renderQuickEntryBox({ onCreate, availableModels: undefined });
expandQuickEntry();
openActionsDropdown();
// Open model menu and select an executor via submenu
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
@@ -1839,6 +1882,7 @@ describe("QuickEntryBox", () => {
it("renders model menu as a portal in document.body (not inside QuickEntryBox)", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
@@ -1857,6 +1901,7 @@ describe("QuickEntryBox", () => {
it("positions the portaled menu with fixed positioning to escape column overflow", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
@@ -1874,6 +1919,7 @@ describe("QuickEntryBox", () => {
it("does not close model menu when clicking inside CustomModelDropdown portal", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
expect(screen.getByTestId("model-nested-menu")).toBeTruthy();
@@ -1907,6 +1953,7 @@ describe("QuickEntryBox", () => {
it("does not close model menu when clicking inside the model-nested-menu portal itself", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
const menu = screen.getByTestId("model-nested-menu");
@@ -1922,6 +1969,7 @@ describe("QuickEntryBox", () => {
it("closes model menu on outside click (click outside both trigger and portal)", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task with menu" } });
@@ -1944,6 +1992,7 @@ describe("QuickEntryBox", () => {
it("repositions portaled menu on window resize while open", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
const menu = screen.getByTestId("model-nested-menu");
@@ -1965,6 +2014,7 @@ describe("QuickEntryBox", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
const menu = screen.getByTestId("model-nested-menu");
@@ -1979,6 +2029,7 @@ describe("QuickEntryBox", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
const menu = screen.getByTestId("model-nested-menu");
@@ -1994,6 +2045,7 @@ describe("QuickEntryBox", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
const menu = screen.getByTestId("model-nested-menu");
@@ -2011,6 +2063,7 @@ describe("QuickEntryBox", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
const menu = screen.getByTestId("model-nested-menu");
@@ -2026,6 +2079,7 @@ describe("QuickEntryBox", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
const menu = screen.getByTestId("model-nested-menu");
@@ -2044,4 +2098,185 @@ describe("QuickEntryBox", () => {
expect(mobileWidth).toBeGreaterThan(desktopWidth);
});
});
describe("Actions dropdown (FN-888)", () => {
it("shows actions trigger button when expanded", () => {
renderQuickEntryBox({});
expandQuickEntry();
// Actions trigger should be visible in controls area
expect(screen.getByTestId("quick-entry-actions-button")).toBeTruthy();
});
it("does not show Deps/Models/Save directly when expanded without opening actions dropdown", () => {
renderQuickEntryBox({});
expandQuickEntry();
// These should NOT be visible until actions dropdown is opened
expect(screen.queryByTestId("quick-entry-deps-button")).toBeNull();
expect(screen.queryByTestId("quick-entry-models-button")).toBeNull();
expect(screen.queryByTestId("save-button")).toBeNull();
});
it("opens actions dropdown on click and shows Deps/Models/Save", () => {
renderQuickEntryBox({});
expandQuickEntry();
// Click the actions trigger
openActionsDropdown();
// Actions dropdown should be visible
expect(screen.getByTestId("actions-dropdown")).toBeTruthy();
// Deps, Models, Save should now be accessible
expect(screen.getByTestId("quick-entry-deps-button")).toBeTruthy();
expect(screen.getByTestId("quick-entry-models-button")).toBeTruthy();
expect(screen.getByTestId("save-button")).toBeTruthy();
});
it("closes actions dropdown on second click", () => {
renderQuickEntryBox({});
expandQuickEntry();
// Open
openActionsDropdown();
expect(screen.getByTestId("actions-dropdown")).toBeTruthy();
// Close by clicking trigger again
fireEvent.click(screen.getByTestId("quick-entry-actions-button"));
expect(screen.queryByTestId("actions-dropdown")).toBeNull();
});
it("closes actions dropdown on Escape (after child popovers are closed)", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Test" } });
// Actions dropdown should be open
expect(screen.getByTestId("actions-dropdown")).toBeTruthy();
// Press Escape — should close actions dropdown
fireEvent.keyDown(textarea, { key: "Escape" });
// Actions dropdown should be closed
expect(screen.queryByTestId("actions-dropdown")).toBeNull();
// Input should still have the value
expect((textarea as HTMLTextAreaElement).value).toBe("Test");
});
it("Escape hierarchy: model submenu → model menu → deps popover → actions dropdown → input clear", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Hierarchy test" } });
// Open model menu
fireEvent.click(screen.getByTestId("quick-entry-models-button"));
expect(screen.getByTestId("model-nested-menu")).toBeTruthy();
// Open executor submenu
fireEvent.click(screen.getByTestId("model-menu-executor"));
expect(screen.getByTestId("model-submenu-back")).toBeTruthy();
// Escape 1: close submenu → back to model menu top level
fireEvent.keyDown(textarea, { key: "Escape" });
expect(screen.queryByTestId("model-submenu-back")).toBeNull();
expect(screen.getByTestId("model-nested-menu")).toBeTruthy();
// Escape 2: close model menu
fireEvent.keyDown(textarea, { key: "Escape" });
expect(screen.queryByTestId("model-nested-menu")).toBeNull();
expect(screen.getByTestId("actions-dropdown")).toBeTruthy();
// Escape 3: close actions dropdown
fireEvent.keyDown(textarea, { key: "Escape" });
expect(screen.queryByTestId("actions-dropdown")).toBeNull();
// Escape 4: clear input and collapse
fireEvent.keyDown(textarea, { key: "Escape" });
expect((textarea as HTMLTextAreaElement).value).toBe("");
expect(textarea.classList.contains("quick-entry-input--expanded")).toBe(false);
});
it("actions dropdown has proper aria attributes on trigger", () => {
renderQuickEntryBox({});
expandQuickEntry();
const trigger = screen.getByTestId("quick-entry-actions-button");
expect(trigger.getAttribute("aria-expanded")).toBe("false");
expect(trigger.getAttribute("aria-haspopup")).toBe("menu");
// Open
openActionsDropdown();
expect(trigger.getAttribute("aria-expanded")).toBe("true");
});
it("renders actions dropdown as a portal in document.body", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const dropdown = screen.getByTestId("actions-dropdown");
expect(dropdown).toBeTruthy();
// The dropdown should be in document.body, NOT inside the QuickEntryBox
const quickEntryBox = screen.getByTestId("quick-entry-box");
expect(quickEntryBox.contains(dropdown)).toBe(false);
expect(document.body.contains(dropdown)).toBe(true);
});
it("positions the actions dropdown with fixed positioning", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const dropdown = screen.getByTestId("actions-dropdown");
expect(dropdown.style.position).toBe("fixed");
expect(dropdown.style.top).toBeTruthy();
expect(dropdown.style.left).toBeTruthy();
expect(dropdown.style.width).toBeTruthy();
});
it("closes actions dropdown when clicking outside", () => {
renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
expect(screen.getByTestId("actions-dropdown")).toBeTruthy();
// Click outside
const outsideElement = document.createElement("div");
document.body.appendChild(outsideElement);
try {
fireEvent.mouseDown(outsideElement);
} finally {
document.body.removeChild(outsideElement);
}
expect(screen.queryByTestId("actions-dropdown")).toBeNull();
});
it("resets actions dropdown state on form reset after creation", async () => {
const { props } = renderQuickEntryBox({});
expandQuickEntry();
openActionsDropdown();
const textarea = screen.getByTestId("quick-entry-input");
fireEvent.change(textarea, { target: { value: "Task to create" } });
// Submit
fireEvent.keyDown(textarea, { key: "Enter" });
await waitFor(() => {
expect(props.onCreate).toHaveBeenCalled();
});
// After creation, actions dropdown should be closed
expect(screen.queryByTestId("actions-dropdown")).toBeNull();
});
});
});