FN-7677: fix quick-add workflow dropdown button height mismatch

Aligns the quick-entry workflow-trigger dropdown button height with the neighboring Save/Fast/Subtask buttons and adds a regression test plus changeset.

- QuickEntryBox.css: .quick-entry-workflow-trigger re-asserts .btn-sm's padding: 4px 10px locally so the shared global .dep-trigger padding: 3px 8px no longer shortens it by ~2px
- Add regression test packages/dashboard/app/__tests__/quick-entry-workflow-trigger-height.test.tsx covering the height parity
- Add changeset fn-7677-quick-add-workflow-trigger-height.md (patch, fix)
- Minor doc updates in cli skill fusion references (extension-tools.md, fusion-capabilities.md)

Files changed:
 .changeset/fn-7677-quick-add-workflow-trigger-height.md            |   7 +
 packages/cli/skill/fusion/references/extension-tools.md            |   6 +-
 packages/cli/skill/fusion/references/fusion-capabilities.md        |   4 +-
 packages/dashboard/app/__tests__/quick-entry-workflow-trigger-height.test.tsx | 248 +++++++++++++++++++++
 packages/dashboard/app/components/QuickEntryBox.css                |   5 +
 5 files changed, 266 insertions(+), 4 deletions(-)

Fusion-Task-Id: FN-7677

Fusion-Task-Lineage: c8cc1b0d-f9c1-4691-a5b4-14ec8ac7e98e

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-08 08:09:53 -07:00
parent 38d883d83e
commit a4fce60b18
5 changed files with 266 additions and 4 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Align the quick-add workflow dropdown button height with Save/Fast/Subtask buttons.
category: fix
dev: `.quick-entry-workflow-trigger` in QuickEntryBox.css now re-asserts `.btn-sm`'s `padding: 4px 10px` locally so the shared global `.dep-trigger` `padding: 3px 8px` no longer shortens it by ~2px; other `.dep-trigger` surfaces (InlineCreateCard, NewTaskModal, TaskDetailModal, TaskForm) are unaffected (FN-7677).

View File

@@ -104,11 +104,12 @@ Request a refinement of a completed or in-review task. Creates a new follow-up t
### fn_task_archive
Archive a task from any live column (move to archived). Archived tasks are preserved for historical reference but moved out of the main board view.
Archive a task from any live column (move to archived). Archived tasks are preserved for historical reference but moved out of the main board view. If the task is still referenced as a lineage parent by another task, archiving is rejected unless removeLineageReferences:true is passed.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | ✓ | Task ID to archive from any live column (e.g. FN-001). |
| `removeLineageReferences` | boolean | — | When true, clear incoming lineage-parent references (child sourceParentTaskId) before archiving, so a task still referenced as a lineage parent can be archived. |
### fn_task_unarchive
@@ -120,12 +121,13 @@ Unarchive an archived task (move from archived → its restore column). Restores
### fn_task_delete
Soft-delete a task from active Fusion board views. The task row and artifacts are preserved; optional allowResurrection marks the ID for intentional recreation.
Soft-delete a task from active Fusion board views. The task row and artifacts are preserved; optional allowResurrection marks the ID for intentional recreation. If the task is still referenced as a lineage parent by another task, deletion is rejected unless removeLineageReferences:true is passed.
| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `id` | string | ✓ | Task ID to delete (e.g. FN-001) |
| `allowResurrection` | boolean | — | When true, mark this tombstone as explicitly reusable for future recreation. |
| `removeLineageReferences` | boolean | — | When true, clear incoming lineage-parent references (child sourceParentTaskId) before deleting, so a task still referenced as a lineage parent can be removed. |
### fn_task_browse_gitlab_project_issues

View File

@@ -30,9 +30,9 @@ All skill/extension tool invocations in this catalog use the public `fn_*` names
| `fn_task_retry` | Retry a failed task — clears the error state. Non-review failures move to todo; in-review execution failures move to todo preserving progress; in-review merge failures stay in-place for auto-merge retry. |
| `fn_task_duplicate` | Duplicate an existing task, creating a fresh copy in planning. Copies the title and description but resets all execution state. The AI planning agent will replan the new task. |
| `fn_task_refine` | Request a refinement of a completed or in-review task. Creates a new follow-up task in planning that references the original task as a dependency. Use this when a done or in-review task needs additional work, improvements, or follow-up changes. |
| `fn_task_archive` | Archive a task from any live column (move to archived). Archived tasks are preserved for historical reference but moved out of the main board view. |
| `fn_task_archive` | Archive a task from any live column (move to archived). Archived tasks are preserved for historical reference but moved out of the main board view. If the task is still referenced as a lineage parent by another task, archiving is rejected unless removeLineageReferences:true is passed. |
| `fn_task_unarchive` | Unarchive an archived task (move from archived → its restore column). Restores to the pre-archive column when available, with active execution columns downgraded to todo. |
| `fn_task_delete` | Soft-delete a task from active Fusion board views. The task row and artifacts are preserved; optional allowResurrection marks the ID for intentional recreation. |
| `fn_task_delete` | Soft-delete a task from active Fusion board views. The task row and artifacts are preserved; optional allowResurrection marks the ID for intentional recreation. If the task is still referenced as a lineage parent by another task, deletion is rejected unless removeLineageReferences:true is passed. |
| `fn_task_import_github` | Import GitHub issues as Fusion tasks. Fetches open issues from a repository and creates tasks in the planning column. Each task includes the issue title and body with a link to the source issue. |
| `fn_task_import_github_issue` | Import a specific GitHub issue as a Fusion task. Fetches the issue by number and creates a single task in the planning column with the issue title and body. |
| `fn_task_browse_github_issues` | List open GitHub issues from a repository to browse before importing. Returns issue numbers, titles, and URLs for selection. Use with fn_task_import_github_issue to import specific issues by number. |

View File

@@ -0,0 +1,248 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { loadAllAppCss, loadStylesCss } from "../test/cssFixture";
import { render, screen, act } from "@testing-library/react";
import { QuickEntryBox } from "../components/QuickEntryBox";
import type { Task } from "@fusion/core";
import type { BoardWorkflowDefinition } from "../api";
import { fetchAgents } from "../api";
/*
FNXC:QuickAddWorkflow 2026-07-08-00:00:
FN-7677 regression coverage. The workflow trigger shares `.dep-trigger` with
InlineCreateCard/NewTaskModal/TaskDetailModal/TaskForm, whose global rule sets
`padding: 3px 8px` and overrides `.btn-sm`'s `padding: 4px 10px`. That made the
quick-add trigger ~2px shorter than the sibling Save/Fast/Subtask `.btn.btn-sm`
buttons. These tests assert the local `.quick-entry-workflow-trigger` override
re-asserts `.btn-sm`'s own padding value (not a new hardcoded literal) so the
box heights resolve equal, and that the fix holds at desktop widths (not just
inside the mobile touch-target `min-height` media block), and that the shared
global `.dep-trigger` rule itself remains untouched for other surfaces.
*/
const mockTasks: Task[] = [
{
id: "FN-001",
title: "Test task 1",
description: "First test task",
column: "todo",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: "2026-01-01T00:00:00Z",
updatedAt: "2026-01-01T00:00:00Z",
},
];
const WORKFLOW_A: BoardWorkflowDefinition = {
id: "builtin:coding",
name: "Coding",
columns: [],
};
const WORKFLOW_B: BoardWorkflowDefinition = {
id: "wf-custom-long-name",
name: "A Rather Long Custom Workflow Name That Should Truncate",
columns: [],
};
vi.mock("../api", () => ({
fetchModels: vi.fn().mockResolvedValue({
models: [],
favoriteProviders: [],
favoriteModels: [],
}),
fetchSettings: vi.fn().mockResolvedValue({
modelPresets: [],
autoSelectModelPreset: false,
defaultPresetBySize: {},
maxConcurrent: 2,
maxWorktrees: 4,
pollIntervalMs: 30000,
groupOverlappingFiles: true,
autoMerge: true,
}),
fetchAgents: vi.fn().mockResolvedValue([]),
fetchWorkflowOptionalSteps: vi.fn().mockResolvedValue([]),
uploadAttachment: vi.fn().mockResolvedValue({}),
updateGlobalSettings: vi.fn().mockResolvedValue({}),
}));
vi.mock("lucide-react", () => ({
Link: () => null,
Paperclip: () => null,
Brain: () => null,
Lightbulb: () => null,
ListTree: () => null,
Sparkles: () => null,
Save: () => null,
X: () => null,
ChevronDown: () => null,
ChevronUp: () => null,
ChevronRight: () => null,
Bot: () => null,
Server: () => null,
Flag: () => null,
Maximize2: () => null,
Minimize2: () => null,
}));
vi.mock("../components/ModelSelectionModal", () => ({
ModelSelectionModal: () => null,
}));
vi.mock("../components/CustomModelDropdown", () => ({
CustomModelDropdown: ({
value,
label,
}: {
value: string;
onChange: (value: string) => void;
label: string;
}) => <div data-testid={`mock-dropdown-${label}`}>{value || "none"}</div>,
}));
function mockDesktopViewport() {
Object.defineProperty(window, "innerWidth", { value: 1280, configurable: true });
return vi.spyOn(window, "matchMedia").mockImplementation((query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
}));
}
function renderQuickEntryBox(props: Record<string, unknown> = {}) {
const defaultProps = {
onCreate: vi.fn().mockResolvedValue(undefined),
addToast: vi.fn(),
tasks: mockTasks,
projectId: "test-proj",
workflowId: WORKFLOW_A.id,
defaultWorkflowId: WORKFLOW_A.id,
workflowOptions: [WORKFLOW_A, WORKFLOW_B],
};
return render(<QuickEntryBox {...defaultProps} {...props} />);
}
describe("quick-entry-workflow-trigger height parity (FN-7677)", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers({ shouldAdvanceTime: true });
localStorage.clear();
vi.mocked(fetchAgents).mockResolvedValue([]);
Object.defineProperty(URL, "createObjectURL", {
configurable: true,
writable: true,
value: vi.fn(() => "blob:mock"),
});
Object.defineProperty(URL, "revokeObjectURL", {
configurable: true,
writable: true,
value: vi.fn(),
});
});
afterEach(async () => {
await act(async () => {
vi.runOnlyPendingTimers();
});
vi.useRealTimers();
localStorage.clear();
});
it("renders the workflow trigger and Save button as siblings in the same action row when ≥2 workflow options exist", () => {
mockDesktopViewport();
renderQuickEntryBox();
const trigger = screen.getByTestId("quick-entry-workflow-trigger");
const saveButton = screen.getByTestId("quick-entry-save");
expect(trigger.classList.contains("btn")).toBe(true);
expect(trigger.classList.contains("btn-sm")).toBe(true);
expect(trigger.classList.contains("dep-trigger")).toBe(true);
expect(saveButton.classList.contains("btn")).toBe(true);
expect(saveButton.classList.contains("btn-sm")).toBe(true);
// Both controls must live in the same .quick-entry-actions row.
const actionsRow = trigger.closest(".quick-entry-actions");
expect(actionsRow).not.toBeNull();
expect(actionsRow?.contains(saveButton)).toBe(true);
});
it("does not render a workflow trigger when fewer than 2 real workflow options exist (no layout regression)", () => {
mockDesktopViewport();
renderQuickEntryBox({ workflowOptions: [WORKFLOW_A] });
expect(screen.queryByTestId("quick-entry-workflow-trigger")).toBeNull();
expect(screen.getByTestId("quick-entry-save")).toBeInTheDocument();
});
it("re-asserts .btn-sm's own padding on .quick-entry-workflow-trigger instead of inheriting the shorter shared .dep-trigger padding", () => {
const cssContent = loadAllAppCss();
// .btn-sm establishes the padding contract the Save/Fast/Subtask buttons resolve.
const btnSmMatch = cssContent.match(/\.btn-sm\s*\{[^}]*padding:\s*([^;]+);/);
expect(btnSmMatch).not.toBeNull();
const btnSmPadding = btnSmMatch![1].trim();
expect(btnSmPadding).toBe("4px 10px");
// The shared global .dep-trigger rule sets a shorter padding and MUST remain
// untouched — other surfaces (InlineCreateCard, NewTaskModal, TaskDetailModal,
// TaskForm) still depend on its 3px/8px sizing.
const depTriggerMatch = cssContent.match(
/\.dep-trigger,\s*\n\s*\.inline-create-model-trigger\s*\{[^}]*padding:\s*([^;]+);/,
);
expect(depTriggerMatch).not.toBeNull();
expect(depTriggerMatch![1].trim()).toBe("3px 8px");
// .quick-entry-workflow-trigger must locally re-assert the .btn-sm padding
// value (not a new arbitrary literal) so cascade order resolves it to the
// same box height as its .btn.btn-sm siblings.
const triggerMatch = cssContent.match(
/\.quick-entry-workflow-trigger\s*\{[^}]*padding:\s*([^;]+);/,
);
expect(triggerMatch).not.toBeNull();
const triggerPadding = triggerMatch![1].trim();
expect(triggerPadding).toBe(btnSmPadding);
expect(triggerPadding).not.toBe(depTriggerMatch![1].trim());
});
it("keeps the height-parity override in the base (non-media-query) rule so desktop widths are covered too, not only the ≤768px touch-target block", () => {
const cssContent = loadAllAppCss();
// Strip everything inside @media blocks to isolate base/desktop rules.
const withoutMediaBlocks = cssContent.replace(/@media[^{]*\{(?:[^{}]*\{[^{}]*\})*[^{}]*\}/g, "");
const baseTriggerMatch = withoutMediaBlocks.match(
/\.quick-entry-workflow-trigger\s*\{[^}]*padding:\s*([^;]+);/,
);
expect(baseTriggerMatch).not.toBeNull();
expect(baseTriggerMatch![1].trim()).toBe("4px 10px");
});
it("does not modify the shared global .dep-trigger rule's selector list (InlineCreateCard/NewTaskModal/TaskDetailModal/TaskForm still share it)", () => {
const stylesCssContent = loadStylesCss();
const depTriggerMatches = stylesCssContent.match(/\.dep-trigger,\s*\n\s*\.inline-create-model-trigger\s*\{/g);
expect(depTriggerMatches).not.toBeNull();
expect(depTriggerMatches!.length).toBe(1);
});
it("keeps the workflow icon, truncating label, and chevron intact when the trigger renders (long label)", () => {
mockDesktopViewport();
renderQuickEntryBox({ workflowOptions: [WORKFLOW_A, WORKFLOW_B], workflowId: WORKFLOW_B.id, defaultWorkflowId: WORKFLOW_B.id });
const trigger = screen.getByTestId("quick-entry-workflow-trigger");
const label = trigger.querySelector(".quick-entry-workflow-label");
expect(label).not.toBeNull();
expect(label?.textContent).toBeTruthy();
// Chevron rendered via mocked lucide-react (ChevronDown -> null), so assert
// the trigger still has non-empty content (icon slot + label), i.e. it is
// not an empty shell.
expect(trigger.textContent?.length ?? 0).toBeGreaterThan(0);
});
});

View File

@@ -209,6 +209,10 @@ The wide menu is portaled and fixed-positioned by QuickEntryBox so right-side bo
max-width: min(calc(var(--space-xl) * 5), 100%);
}
/*
FNXC:QuickAddWorkflow 2026-07-08-00:00:
FN-7677 — the workflow trigger shares `.btn.btn-sm.dep-trigger` classes with other surfaces (InlineCreateCard, NewTaskModal, TaskDetailModal, TaskForm), but the shared `.dep-trigger, .inline-create-model-trigger` rule in styles.css sets `padding: 3px 8px`, overriding `.btn-sm`'s `padding: 4px 10px` and making this trigger ~2px shorter than its Save/Fast/Subtask `.btn.btn-sm` siblings in `.quick-entry-actions`. Re-assert `.btn-sm`'s own padding value scoped to this selector only (do not touch the shared global `.dep-trigger` rule — other surfaces still depend on its 3px/8px sizing) so the quick-add action row reads as one uniform height.
*/
.quick-entry-workflow-trigger {
display: inline-flex;
align-items: center;
@@ -217,6 +221,7 @@ The wide menu is portaled and fixed-positioned by QuickEntryBox so right-side bo
width: auto;
min-width: 0;
max-width: min(calc(var(--space-xl) * 5), calc(100vw - var(--space-lg)));
padding: 4px 10px;
}
.quick-entry-workflow-icon {