From 66e91f96ae94e35f67f505c5900ca71b5d1db752 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Sat, 11 Jul 2026 21:24:28 -0700 Subject: [PATCH] FN-7837: fix task card size badge wrapping onto a misaligned second row Groups TaskCard header badges so the id and right-aligned size/actions cluster never wrap, fixing the S/M/L size chip dropping to its own row when extra badges (fast-mode, priority, oversight, PR/GitHub, etc.) are present. - Introduce hasHeaderBadges/hasHeaderActions guards and wrap the middle badge cluster in a new .card-header-badges container so only .card-header-badges wraps, while .card-id and .card-header-actions stay pinned to the top row - Update TaskCard.css: .card-header becomes a non-wrapping flex row; .card-header-badges takes over the wrapping/flex-grow behavior previously on .card-header; .card-id gets flex-shrink: 0; .card-header-actions gets flex: 0 0 auto and align-self: flex-start; mobile media query gains .card-header-badges alongside .card-meta-badges - Add FNXC:TaskCardLayout comments documenting the FN-7837 layout requirement (non-wrapping header row, wrapping badge cluster) - Extend TaskCard.badge-wrap.test.tsx and TaskCard.test.tsx coverage for the new header grouping/wrap behavior - Add changeset FN-7837-card-size-badge-alignment.md (patch, fix) for @runfusion/fusion Files changed: .changeset/FN-7837-card-size-badge-alignment.md | 7 +++ packages/dashboard/app/components/TaskCard.css | 29 +++++++-- packages/dashboard/app/components/TaskCard.tsx | 38 ++++++++++++ .../__tests__/TaskCard.badge-wrap.test.tsx | 70 ++++++++++++++++++++-- .../app/components/__tests__/TaskCard.test.tsx | 27 ++++++--- 5 files changed, 156 insertions(+), 15 deletions(-) Fusion-Task-Id: FN-7837 Fusion-Task-Lineage: 9f70abcf-3b23-41b3-8642-aa34a561996d Co-authored-by: Fusion (runfusion.ai) --- .../FN-7837-card-size-badge-alignment.md | 7 ++ .../dashboard/app/components/TaskCard.css | 31 ++++++-- .../dashboard/app/components/TaskCard.tsx | 38 ++++++++++ .../__tests__/TaskCard.badge-wrap.test.tsx | 70 +++++++++++++++++-- .../components/__tests__/TaskCard.test.tsx | 27 +++++-- 5 files changed, 157 insertions(+), 16 deletions(-) create mode 100644 .changeset/FN-7837-card-size-badge-alignment.md diff --git a/.changeset/FN-7837-card-size-badge-alignment.md b/.changeset/FN-7837-card-size-badge-alignment.md new file mode 100644 index 0000000000..2a26f43d27 --- /dev/null +++ b/.changeset/FN-7837-card-size-badge-alignment.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Task card size badge (S/M/L) no longer drops onto a misaligned second row on cards with extra badges. +category: fix +dev: Groups the wrapping header status/meta badges in TaskCard so `.card-id` and the right-aligned `.card-header-actions` (holding `.card-size-badge`) stay on the top row; fixes the fast-mode (`.card-execution-mode-badge`) orphaned-size-chip case (FN-7832 repro). diff --git a/packages/dashboard/app/components/TaskCard.css b/packages/dashboard/app/components/TaskCard.css index e7e952bcca..64680b513d 100644 --- a/packages/dashboard/app/components/TaskCard.css +++ b/packages/dashboard/app/components/TaskCard.css @@ -96,14 +96,32 @@ Mobile long-press opens the Board task context menu, so non-editing card text mu .card-header { display: flex; - align-items: center; - flex-wrap: wrap; + align-items: flex-start; + flex-wrap: nowrap; gap: var(--space-xs); row-gap: var(--space-xs); min-width: 0; margin-bottom: var(--space-xs); } +/* +FNXC:TaskCardLayout 2026-07-11-00:00: +FN-7837 keeps the id row and right-aligned size/actions cluster together while extra header badges wrap inside this middle group, preventing the size chip from orphaning onto a misaligned second row on desktop and mobile. +*/ +.card-header-badges { + display: flex; + align-items: center; + flex: 1 1 auto; + flex-wrap: wrap; + gap: var(--space-xs); + row-gap: var(--space-xs); + min-width: 0; +} + +.card-id { + flex-shrink: 0; +} + .card-meta-badges { display: flex; align-items: center; @@ -1408,7 +1426,9 @@ executing. These map 1:1 to the unified progress status so the dot color encodes .card-header-actions { display: flex; align-items: center; - gap: 6px; + align-self: flex-start; + flex: 0 0 auto; + gap: var(--space-xs); margin-left: auto; } @@ -1773,9 +1793,9 @@ too so future divergence doesn't have to rediscover the shared selector group. height: 16px; } - /* FN-4365/FN-4351: keep secondary actions compact and inline with the header title row on mobile. */ + /* FN-4365/FN-4351/FN-7837: keep secondary actions compact, token-spaced, and inline with the non-wrapping header title row on mobile. */ .card-header-actions { - gap: 4px; + gap: calc(var(--space-xs) / 2); flex-shrink: 0; } @@ -1835,6 +1855,7 @@ too so future divergence doesn't have to rediscover the shared selector group. } + .card-header-badges, .card-meta-badges { gap: calc(var(--space-xs) / 2); row-gap: var(--space-xs); diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 5ca8af1df1..1b0a93482c 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -2757,6 +2757,34 @@ function TaskCardComponent({ // metadata (absent for the common "off" default) — include it in the wrapper // guard so `.card-meta-badges` only renders when it has a real child. || showOversightBadge; + const hasHeaderBadges = Boolean(isPaused) + || Boolean(!isPaused && visualStatus && visualStatus !== "queued") + || planReviewRunning + || Boolean(!isPaused && task.column === "todo" && !visualStatus && (task.steps?.length ?? 0) > 0) + || Boolean(hasInReviewStall && stallCopy) + || cliWaitingOnInput + || cliNeedsAttention + || Boolean(hasStalePausedReview && stalePausedReviewCopy) + || Boolean(hasTaskAgeStaleness && taskAgeStalenessCopy) + || Boolean(isStuck && (isPaused || !task.status || task.status === "queued")) + || Boolean(Array.isArray((task as TaskWithBranchProgress).branchProgress) && (task as TaskWithBranchProgress).branchProgress!.length > 0) + || Boolean(task.plannerOverseerState && task.plannerOverseerState.state !== "idle") + || Boolean(showStalledReview && stalledReview) + || Boolean(livePrInfo || liveIssueInfo) + || Boolean(task.gitlabTracking?.item) + || Boolean(prNode) + || hasCardMetaBadges + || task.noCommitsExpected === true + || Boolean(task.missionId); + const hasHeaderActions = Boolean(isAwaitingInput && onOpenDetailWithTab) + || Boolean(canEdit) + || Boolean(task.column === "triage" && onDeleteTask) + || Boolean(task.column === "done" && onArchiveTask) + || Boolean(task.column === "archived" && onUnarchiveTask) + || Boolean((task.column === "done" || task.column === "archived") && onRevertTask && isRevertable) + || Boolean(task.column === "in-progress" && onMoveTask) + || Boolean(task.size) + || hasContextMenuActions; if (isEditing) { return ( @@ -2834,6 +2862,12 @@ function TaskCardComponent({ )}
{task.id} + {hasHeaderBadges && ( + /* + FNXC:TaskCardLayout 2026-07-11-00:00: + FN-7837 keeps the task id and right-aligned size/actions cluster in the same non-wrapping header row. Extra header badges (fast-mode, priority, oversight, decision-only, PR/GitHub, and status chips) wrap inside this middle group instead of pushing the size chip onto a misaligned second row on desktop or mobile. + */ +
{isPaused && ( )} +
+ )} + {hasHeaderActions && (
{isAwaitingInput && onOpenDetailWithTab && (
+ )}
{showStalledReview && stalledReview && (
diff --git a/packages/dashboard/app/components/__tests__/TaskCard.badge-wrap.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.badge-wrap.test.tsx index 8d23797502..7cc208275a 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.badge-wrap.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.badge-wrap.test.tsx @@ -23,6 +23,7 @@ vi.mock("lucide-react", () => ({ Zap: () => null, AlertTriangle: () => null, Eye: () => null, + MoreHorizontal: () => null, })); vi.mock("../ProviderIcon", () => ({ @@ -98,13 +99,74 @@ describe("TaskCard badge wrapping (FN-5162)", () => { cleanupCss = undefined; }); - it("wraps the header row and keeps a non-zero row gap", () => { + it("keeps the outer header row non-wrapping while badges wrap inside their own group", () => { const header = container.querySelector(".card-header"); + const headerBadges = container.querySelector(".card-header-badges"); expect(header).toBeTruthy(); + expect(headerBadges).toBeTruthy(); - const styles = getComputedStyle(header!); - expect(styles.flexWrap).toBe("wrap"); - expect(styles.rowGap).toMatch(/^(var\(--space-xs\)|(?!0px$)\d+(?:\.\d+)?px)$/); + const headerStyles = getComputedStyle(header!); + expect(headerStyles.flexWrap).toBe("nowrap"); + expect(headerStyles.rowGap).toMatch(/^(var\(--space-xs\)|(?!0px$)\d+(?:\.\d+)?px)$/); + + const badgeStyles = getComputedStyle(headerBadges!); + expect(badgeStyles.display).toBe("flex"); + expect(badgeStyles.flexWrap).toBe("wrap"); + expect(badgeStyles.minWidth).toBe("0px"); + expect(header?.contains(headerBadges)).toBe(true); + expect(container.querySelector(".card-header-actions")).toBeNull(); + }); + + it("keeps a fast-mode size badge in the right-aligned header actions instead of an orphaned wrapped row", () => { + const { container: sizedContainer } = render( + makeTask()} + workflowBadge={{ workflowId: "wf-fast-size", workflowName: "Fast size workflow" }} + />, + ); + + const header = sizedContainer.querySelector(".card-header") as HTMLElement; + const headerBadges = sizedContainer.querySelector(".card-header-badges") as HTMLElement; + const actions = sizedContainer.querySelector(".card-header-actions") as HTMLElement; + const sizeBadge = sizedContainer.querySelector(".card-size-badge") as HTMLElement; + const fastBadge = sizedContainer.querySelector(".card-execution-mode-badge") as HTMLElement; + + expect(header).toBeTruthy(); + expect(headerBadges).toBeTruthy(); + expect(actions).toBeTruthy(); + expect(sizeBadge).toBeTruthy(); + expect(fastBadge).toBeTruthy(); + expect(actions.contains(sizeBadge)).toBe(true); + expect(headerBadges.contains(fastBadge)).toBe(true); + expect(sizeBadge.closest(".card-header-badges")).toBeNull(); + expect(actions.parentElement).toBe(header); + expect(headerBadges.parentElement).toBe(header); + + const headerStyles = getComputedStyle(header); + const actionsStyles = getComputedStyle(actions); + expect(headerStyles.flexWrap).toBe("nowrap"); + expect(actionsStyles.marginLeft).toBe("auto"); + expect(actionsStyles.flexShrink).toBe("0"); + expect(actionsStyles.alignSelf).toBe("flex-start"); }); it.each([ diff --git a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx index aa7442f652..a16c5c8dd0 100644 --- a/packages/dashboard/app/components/__tests__/TaskCard.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskCard.test.tsx @@ -2029,8 +2029,10 @@ describe("TaskCard", () => { const badge = container.querySelector(".card-status-badge")!; expect(cardId).toBeDefined(); expect(badge).toBeDefined(); - // Badge should be the next sibling of card-id - expect(cardId.nextElementSibling).toBe(badge); + const headerBadges = container.querySelector(".card-header-badges")!; + expect(headerBadges).toBeDefined(); + expect(cardId.nextElementSibling).toBe(headerBadges); + expect(headerBadges.contains(badge)).toBe(true); }); it("does not render a status badge when task.status is falsy", () => { @@ -3401,18 +3403,29 @@ describe("TaskCard", () => { expect(actionsContainer?.contains(sizeBadge)).toBe(true); }); - it("places card-header-actions after card-id in DOM order", () => { + it("places card-header-actions as a direct header child after the wrapped badge group", () => { const { container } = render( - , + , ); + const header = container.querySelector(".card-header")!; const cardId = container.querySelector(".card-id")!; + const headerBadges = container.querySelector(".card-header-badges")!; const actionsContainer = container.querySelector(".card-header-actions")!; - + expect(cardId).not.toBeNull(); + expect(headerBadges).not.toBeNull(); expect(actionsContainer).not.toBeNull(); - // The actions container should come after card-id + expect(actionsContainer.parentElement).toBe(header); + expect(headerBadges.parentElement).toBe(header); expect( - cardId.compareDocumentPosition(actionsContainer) & Node.DOCUMENT_POSITION_FOLLOWING + cardId.compareDocumentPosition(headerBadges) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + expect( + headerBadges.compareDocumentPosition(actionsContainer) & Node.DOCUMENT_POSITION_FOLLOWING ).toBeTruthy(); });