diff --git a/.changeset/fn-7677-quick-add-workflow-trigger-height.md b/.changeset/fn-7677-quick-add-workflow-trigger-height.md new file mode 100644 index 0000000000..f540273671 --- /dev/null +++ b/.changeset/fn-7677-quick-add-workflow-trigger-height.md @@ -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). diff --git a/packages/cli/skill/fusion/references/extension-tools.md b/packages/cli/skill/fusion/references/extension-tools.md index 46ee4e8d4f..ae18e41de0 100644 --- a/packages/cli/skill/fusion/references/extension-tools.md +++ b/packages/cli/skill/fusion/references/extension-tools.md @@ -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 diff --git a/packages/cli/skill/fusion/references/fusion-capabilities.md b/packages/cli/skill/fusion/references/fusion-capabilities.md index 674bd8b842..099f4a9ce0 100644 --- a/packages/cli/skill/fusion/references/fusion-capabilities.md +++ b/packages/cli/skill/fusion/references/fusion-capabilities.md @@ -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. | diff --git a/packages/dashboard/app/__tests__/quick-entry-workflow-trigger-height.test.tsx b/packages/dashboard/app/__tests__/quick-entry-workflow-trigger-height.test.tsx new file mode 100644 index 0000000000..4c2626b056 --- /dev/null +++ b/packages/dashboard/app/__tests__/quick-entry-workflow-trigger-height.test.tsx @@ -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; + }) =>
{value || "none"}
, +})); + +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 = {}) { + 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(); +} + +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); + }); +}); diff --git a/packages/dashboard/app/components/QuickEntryBox.css b/packages/dashboard/app/components/QuickEntryBox.css index 11956aefbd..dccdf124b7 100644 --- a/packages/dashboard/app/components/QuickEntryBox.css +++ b/packages/dashboard/app/components/QuickEntryBox.css @@ -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 {