diff --git a/.changeset/fn-8492-mobile-task-footer-single-row.md b/.changeset/fn-8492-mobile-task-footer-single-row.md
new file mode 100644
index 0000000000..5316563fa6
--- /dev/null
+++ b/.changeset/fn-8492-mobile-task-footer-single-row.md
@@ -0,0 +1,7 @@
+---
+"@runfusion/fusion": patch
+---
+
+summary: Keep task detail footer actions on a single row on mobile.
+category: fix
+dev: Mobile TaskDetailModal `.modal-actions` nowrap + tokenized compression so Actions/Move/Merge fit without overflow (FN-8492).
diff --git a/packages/dashboard/app/components/TaskDetailModal.css b/packages/dashboard/app/components/TaskDetailModal.css
index 6cd9eca4f2..37cda58ae5 100644
--- a/packages/dashboard/app/components/TaskDetailModal.css
+++ b/packages/dashboard/app/components/TaskDetailModal.css
@@ -3394,16 +3394,82 @@ Live and Feed Activity expansion overlays the content instead of reserving a too
font-size: 16px;
}
- .modal-actions {
- flex-wrap: wrap;
- gap: var(--space-sm);
+ /*
+ FNXC:TaskDetailModalResponsive 2026-07-22-00:00:
+ FN-8492 requires the task-detail footer to keep Actions, Move, and the
+ Merge/review control on one row from 320 CSS px through the 768px mobile
+ breakpoint without horizontal overflow. `nowrap` alone only trades wrapping
+ for overflow, so scoped tokenized packing, flex shrink guards, and label
+ ellipsis let every text-bearing footer control yield while upward menus stay
+ unclipped.
+ */
+ .task-detail-content .modal-actions {
+ flex-wrap: nowrap;
+ align-items: center;
+ gap: var(--space-xs);
padding: var(--space-md) calc(var(--space-md) + var(--space-xs) / 2) calc(var(--space-md) + env(safe-area-inset-bottom, 0px));
}
- .modal-actions .btn {
+ .task-detail-content .modal-actions-spacer {
+ flex: 1 1 0;
min-width: 0;
}
+ .task-detail-content .detail-actions-dropdown,
+ .task-detail-content .detail-move-dropdown {
+ min-width: 0;
+ flex-shrink: 1;
+ }
+
+ .task-detail-content .detail-move-dropdown {
+ flex: 1 1 auto;
+ }
+
+ .task-detail-content .detail-move-actions-in-review {
+ display: flex;
+ flex: 1 1 auto;
+ flex-wrap: nowrap;
+ align-items: center;
+ min-width: 0;
+ gap: var(--space-xs);
+ }
+
+ .task-detail-content .detail-move-actions-in-review > div,
+ .task-detail-content .detail-move-actions-in-review > .btn {
+ flex: 1 1 0;
+ min-width: 0;
+ }
+
+ .task-detail-content .modal-actions .btn {
+ flex: 0 1 auto;
+ min-width: 0;
+ max-width: 100%;
+ padding-inline: var(--space-xs);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+
+ .task-detail-content .detail-move-btn {
+ display: flex;
+ min-width: 0;
+ max-width: 100%;
+ }
+
+ .task-detail-content .detail-footer-button-label,
+ .task-detail-content .detail-move-btn__label {
+ flex: 1 1 auto;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+ }
+
+ .task-detail-content .detail-move-btn__arrow,
+ .task-detail-content .modal-actions .btn > svg {
+ flex-shrink: 0;
+ }
+
/* Mobile dropdown menus — position above action bar, constrained to viewport */
.detail-actions-menu,
.detail-move-menu {
diff --git a/packages/dashboard/app/components/TaskDetailModal.tsx b/packages/dashboard/app/components/TaskDetailModal.tsx
index aa1dbcf176..67ca662a4c 100644
--- a/packages/dashboard/app/components/TaskDetailModal.tsx
+++ b/packages/dashboard/app/components/TaskDetailModal.tsx
@@ -6334,7 +6334,9 @@ export function TaskDetailContent({
aria-haspopup="menu"
aria-expanded={showActionsMenu}
>
- {t("taskDetail.actions.menuBtn", "Actions")}
+
+ {t("taskDetail.actions.menuBtn", "Actions")}
+
{showActionsMenu && (
@@ -6407,7 +6409,9 @@ export function TaskDetailContent({
onClick={reviewAction.onSelect}
disabled={reviewAction.disabled}
>
- {reviewAction.label}
+
+ {reviewAction.label}
+
)}
diff --git a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx
index e3b69405a7..4ec011bec8 100644
--- a/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx
+++ b/packages/dashboard/app/components/__tests__/TaskDetailModal.responsive-and-dependencies.test.tsx
@@ -886,6 +886,96 @@ describe("TaskDetailModal", () => {
expect((container.querySelector(".detail-tabs") as HTMLElement).style.borderBottom).toBe("");
});
+ it("keeps every mobile task footer control shrinkable on one row from 320 CSS px through 768px", () => {
+ const css = readDashboardStylesSource();
+ const mobileBlock = getCssAtRuleBlockContainingExactRule(css, "@media (max-width: 768px)", ".task-detail-content .modal-actions");
+ const footerBlock = getExactCssRuleBlock(mobileBlock, ".task-detail-content .modal-actions");
+ const spacerBlock = getExactCssRuleBlock(mobileBlock, ".task-detail-content .modal-actions-spacer");
+ const inReviewBlock = getExactCssRuleBlock(mobileBlock, ".task-detail-content .detail-move-actions-in-review");
+ const buttonBlock = getExactCssRuleBlock(mobileBlock, ".task-detail-content .modal-actions .btn");
+ const labelBlock = getExactCssRuleBlock(
+ mobileBlock,
+ ".task-detail-content .detail-footer-button-label,\n .task-detail-content .detail-move-btn__label",
+ );
+ const dropdownBlock = getExactCssRuleBlock(
+ mobileBlock,
+ ".task-detail-content .detail-actions-dropdown,\n .task-detail-content .detail-move-dropdown",
+ );
+ const expandedChatBlock = getExactCssRuleBlock(css, ".task-detail-content--chat-expanded .modal-actions");
+
+ /*
+ FNXC:TaskDetailModalResponsive 2026-07-22-00:00:
+ FN-8492's supported mobile fit contract is 320 CSS px through 768px. Keep
+ these source assertions together: `nowrap` without shrink guards and
+ text ellipsis would merely turn the original second row into overflow.
+ */
+ expect(footerBlock).toContain("flex-wrap: nowrap;");
+ expect(footerBlock).not.toContain("flex-wrap: wrap;");
+ expect(footerBlock).toContain("align-items: center;");
+ expect(footerBlock).toContain("gap: var(--space-xs);");
+ expect(spacerBlock).toContain("flex: 1 1 0;");
+ expect(spacerBlock).toContain("min-width: 0;");
+ expect(dropdownBlock).toContain("min-width: 0;");
+ expect(dropdownBlock).toContain("flex-shrink: 1;");
+ expect(inReviewBlock).toContain("display: flex;");
+ expect(inReviewBlock).toContain("flex-wrap: nowrap;");
+ expect(inReviewBlock).toContain("min-width: 0;");
+ expect(inReviewBlock).toContain("gap: var(--space-xs);");
+ expect(buttonBlock).toContain("flex: 0 1 auto;");
+ expect(buttonBlock).toContain("min-width: 0;");
+ expect(buttonBlock).toContain("padding-inline: var(--space-xs);");
+ expect(buttonBlock).toContain("white-space: nowrap;");
+ expect(buttonBlock).toContain("overflow: hidden;");
+ expect(buttonBlock).toContain("text-overflow: ellipsis;");
+ expect(labelBlock).toContain("min-width: 0;");
+ expect(labelBlock).toContain("white-space: nowrap;");
+ expect(labelBlock).toContain("overflow: hidden;");
+ expect(labelBlock).toContain("text-overflow: ellipsis;");
+ expect(expandedChatBlock).toContain("display: none;");
+ expect(css).toMatch(/\.task-detail-content--planner-chat-expanded \.modal-actions,[\s\S]*?\{\s*display:\s*none;/);
+ });
+
+ it("keeps dense in-review and standard task controls in their shared footer", () => {
+ const { container, unmount } = render(
+ ,
+ );
+ const inReviewFooter = container.querySelector(".modal-actions");
+
+ expect(inReviewFooter).toBeTruthy();
+ expect(inReviewFooter?.contains(screen.getByRole("button", { name: "Actions" }))).toBe(true);
+ expect(inReviewFooter?.querySelector(".detail-move-btn")).toBeTruthy();
+ expect(inReviewFooter?.contains(screen.getByRole("button", { name: "Merge & Close" }))).toBe(true);
+
+ unmount();
+
+ const standard = render(
+ ,
+ );
+ const standardFooter = standard.container.querySelector(".modal-actions");
+
+ expect(standardFooter).toBeTruthy();
+ expect(standardFooter?.contains(screen.getByRole("button", { name: "Actions" }))).toBe(true);
+ expect(standardFooter?.querySelector(".detail-move-btn")).toBeTruthy();
+ });
+
it("modal-actions contains Delete and Pause buttons for non-done tasks (via Actions dropdown)", () => {
render(
{
/>,
);
- const button = screen.getByText("Awaiting PR checks") as HTMLButtonElement;
+ const button = screen.getByText("Awaiting PR checks").closest("button") as HTMLButtonElement;
expect(button.disabled).toBe(true);
expect(screen.queryByText("Merge & Close")).toBeNull();
});
@@ -1697,7 +1787,7 @@ describe("TaskDetailModal", () => {
/>,
);
- const button = screen.getByText("Creating PR…") as HTMLButtonElement;
+ const button = screen.getByText("Creating PR…").closest("button") as HTMLButtonElement;
expect(button.disabled).toBe(true);
expect(screen.queryByText("Merge & Close")).toBeNull();
});