From 37db66965c0dd200155dd8aec76597049b483b12 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 12:12:53 -0700 Subject: [PATCH] FN-7958: loosen mobile agent header and overview hero spacing Ease cramped mobile agent detail identity and Overview hero rows while preserving the non-overlap header grid. - Increase mobile header padding, column/row gaps, and identity gap for breathing room - Add badge wrap gaps and keep lifecycle controls on the FN-6865 non-overlap grid - Give Overview hero heading/meta/skills deliberate row gaps and wrap long health text - Nudge summary-card padding and hero gaps further under 480px - Extend mobile scroll and core tests for spacing invariants Files changed: packages/dashboard/app/components/AgentDetailView.css | 51 +++++- .../__tests__/AgentDetailView.core.test.tsx | 4 + .../AgentDetailView.mobile-scroll.test.tsx | 182 ++++++++++++++++++++- 3 files changed, 230 insertions(+), 7 deletions(-) Fusion-Task-Id: FN-7958 Fusion-Task-Lineage: c555811f-9dce-4725-81b5-2705aeaaa1db Co-authored-by: Fusion (runfusion.ai) --- .../app/components/AgentDetailView.css | 51 ++++- .../__tests__/AgentDetailView.core.test.tsx | 4 + .../AgentDetailView.mobile-scroll.test.tsx | 182 +++++++++++++++++- 3 files changed, 230 insertions(+), 7 deletions(-) diff --git a/packages/dashboard/app/components/AgentDetailView.css b/packages/dashboard/app/components/AgentDetailView.css index 289b4a4e97..ac54ca90a9 100644 --- a/packages/dashboard/app/components/AgentDetailView.css +++ b/packages/dashboard/app/components/AgentDetailView.css @@ -1695,14 +1695,18 @@ FNXC:AgentDetailView 2026-06-26-01:00: resize: none; } + /* + FNXC:AgentDetailMobileHeader 2026-07-14-00:00: + Mobile agent detail identity, status badges, and lifecycle controls must keep the FN-6865 non-overlap grid while adding deliberate breathing room so paused/error health text does not read as cramped. + */ .agent-detail-header { display: grid; grid-template-columns: minmax(0, 1fr) auto; align-items: center; - column-gap: var(--space-xs); - row-gap: 0; - padding: var(--space-sm) var(--space-md); - padding-top: max(var(--space-sm), env(safe-area-inset-top, 0)); + column-gap: var(--space-sm); + row-gap: var(--space-sm); + padding: var(--space-md); + padding-top: max(var(--space-md), env(safe-area-inset-top, 0)); } .agent-detail-identity { @@ -1710,7 +1714,7 @@ FNXC:AgentDetailView 2026-06-26-01:00: grid-row: 1; flex: 1 1 auto; min-width: 0; - gap: var(--space-sm); + gap: var(--space-md); } .agent-detail-icon { @@ -1733,6 +1737,9 @@ FNXC:AgentDetailView 2026-06-26-01:00: .agent-detail-badges { flex-wrap: wrap; + row-gap: var(--space-xs); + column-gap: var(--space-sm); + margin-top: var(--space-xs); } .agent-detail-header-actions { @@ -1869,9 +1876,30 @@ FNXC:AgentDetailView 2026-06-26-01:00: gap: var(--space-lg); } + /* + FNXC:AgentDetailOverviewHero 2026-07-14-00:00: + The mobile Overview hero is part of the perceived agent header; let the name, state badge, health line, role/model text, and skills chips wrap with deliberate row gaps so long pause reasons and many skills do not crowd adjacent rows. + */ + .dashboard-summary-hero__heading { + flex-wrap: wrap; + row-gap: var(--space-sm); + column-gap: var(--space-md); + } + + .dashboard-summary-hero__meta { + align-items: flex-start; + row-gap: var(--space-lg); + column-gap: var(--space-md); + } + + .dashboard-summary-hero__health { + overflow-wrap: anywhere; + } + .dashboard-summary-skills { flex-direction: column; align-items: flex-start; + row-gap: var(--space-sm); } .dashboard-summary-skill-detail-header { @@ -1990,6 +2018,19 @@ FNXC:AgentDetailView 2026-06-26-01:00: } @media (max-width: 480px) { + .dashboard-summary-card { + padding: var(--space-md); + } + + .dashboard-summary-hero__heading { + row-gap: var(--space-md); + } + + .dashboard-summary-hero__meta { + row-gap: var(--space-lg); + column-gap: var(--space-sm); + } + .info-grid { grid-template-columns: 1fr; } diff --git a/packages/dashboard/app/components/__tests__/AgentDetailView.core.test.tsx b/packages/dashboard/app/components/__tests__/AgentDetailView.core.test.tsx index 410d6d9819..84f6b48faa 100644 --- a/packages/dashboard/app/components/__tests__/AgentDetailView.core.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentDetailView.core.test.tsx @@ -1195,8 +1195,12 @@ it("keeps mobile inline header controls on the same row as identity", () => { expect(stylesContent).toContain("@media (max-width: 768px)"); expect(stylesContent).toContain(".agent-detail-header {"); expect(stylesContent).toContain("grid-template-columns: minmax(0, 1fr) auto;"); + expect(stylesContent).toContain("column-gap: var(--space-sm);"); + expect(stylesContent).toContain("row-gap: var(--space-sm);"); + expect(stylesContent).toContain("padding: var(--space-md);"); expect(stylesContent).toContain(".agent-detail-identity {"); expect(stylesContent).toContain("grid-column: 1;"); + expect(stylesContent).toContain("gap: var(--space-md);"); expect(stylesContent).toContain(".agent-detail-header-actions {"); expect(stylesContent).toContain("grid-column: 2;"); expect(stylesContent).toContain(".agent-detail-controls .agent-detail-mobile-icon-control {"); diff --git a/packages/dashboard/app/components/__tests__/AgentDetailView.mobile-scroll.test.tsx b/packages/dashboard/app/components/__tests__/AgentDetailView.mobile-scroll.test.tsx index 310ebdded6..29fcd32c47 100644 --- a/packages/dashboard/app/components/__tests__/AgentDetailView.mobile-scroll.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentDetailView.mobile-scroll.test.tsx @@ -5,12 +5,12 @@ import { loadAllAppCss, loadAllAppCssBaseOnly } from "../../test/cssFixture"; import { createMockAgent, mockFetchAgent, setupAgentDetailMocks } from "./AgentDetailView.test-helpers"; import { AgentDetailView } from "../AgentDetailView"; -function installAgentDetailMatchMedia(matchesMobile: boolean) { +function installAgentDetailMatchMedia(matchesMobile: boolean, matchesNarrow = matchesMobile) { Object.defineProperty(window, "matchMedia", { configurable: true, writable: true, value: vi.fn().mockImplementation((query: string) => ({ - matches: matchesMobile && query.includes("max-width: 768px"), + matches: matchesMobile && (query.includes("max-width: 768px") || (matchesNarrow && query.includes("max-width: 480px"))), media: query, onchange: null, addListener: vi.fn(), @@ -22,10 +22,74 @@ function installAgentDetailMatchMedia(matchesMobile: boolean) { }); } +function extractMediaCss(css: string, mediaQuery: string): string { + const marker = `@media (${mediaQuery})`; + const blocks: string[] = []; + let searchFrom = 0; + while (searchFrom < css.length) { + const start = css.indexOf(marker, searchFrom); + if (start === -1) break; + const open = css.indexOf("{", start); + if (open === -1) break; + let depth = 1; + let i = open + 1; + while (i < css.length && depth > 0) { + if (css[i] === "{") depth++; + if (css[i] === "}") depth--; + i++; + } + blocks.push(css.slice(open + 1, i - 1)); + searchFrom = i; + } + return blocks.join("\n"); +} + +function extractSelectorRule(css: string, selector: string): string { + const ruleStart = `${selector} {`; + const start = css.indexOf(ruleStart); + if (start === -1) return ""; + const open = css.indexOf("{", start); + if (open === -1) return ""; + let depth = 1; + let i = open + 1; + while (i < css.length && depth > 0) { + if (css[i] === "{") depth++; + if (css[i] === "}") depth--; + i++; + } + return css.slice(start, i); +} + +function appendAgentDetailMobileCssForJsdom(includeNarrow = false) { + const styles = loadAllAppCss(); + const mobileCss = extractMediaCss(styles, "max-width: 768px"); + const narrowCss = includeNarrow ? extractMediaCss(styles, "max-width: 480px") : ""; + const style = document.createElement("style"); + style.setAttribute("data-testid", "fn-7958-mobile-css"); + // FNXC:AgentDetailMobileHeader 2026-07-14-00:00: jsdom does not evaluate viewport media queries from matchMedia, so spacing tests append the real selector rules from the media blocks after loading all app CSS to assert browser-effective mobile values without duplicating one-off CSS literals. + style.textContent = [ + ".agent-detail-header", + ".agent-detail-identity", + ".agent-detail-badges", + ".agent-detail-header-actions", + ".dashboard-summary-card", + ".dashboard-summary-hero__heading", + ".dashboard-summary-hero__meta", + ".dashboard-summary-hero__health", + ".dashboard-summary-skills", + ].map((selector) => extractSelectorRule(mobileCss, selector)).join("\n") + "\n" + [ + ".dashboard-summary-card", + ".dashboard-summary-hero__heading", + ".dashboard-summary-hero__meta", + ].map((selector) => extractSelectorRule(narrowCss, selector)).join("\n"); + document.head.appendChild(style); +} + describe("AgentDetailView mobile scroll regression (FN-4231)", () => { beforeEach(() => { setupAgentDetailMocks(); document.head.querySelector("style[data-testid='fn-4231-css']")?.remove(); + document.head.querySelector("style[data-testid='fn-7958-mobile-css']")?.remove(); const style = document.createElement("style"); style.setAttribute("data-testid", "fn-4231-css"); style.textContent = loadAllAppCss(); @@ -51,6 +115,120 @@ describe("AgentDetailView mobile scroll regression (FN-4231)", () => { expect(window.getComputedStyle(footerEl).flexShrink).toBe("0"); }); + it("adds mobile breathing room to the header without changing desktop spacing (FN-7958)", async () => { + installAgentDetailMatchMedia(false); + const baseStyle = document.head.querySelector("style[data-testid='fn-4231-css']") as HTMLStyleElement; + baseStyle.textContent = loadAllAppCssBaseOnly(); + + mockFetchAgent.mockResolvedValueOnce(createMockAgent({ state: "paused", pauseReason: "heartbeat-model-unavailable" })); + const desktopRender = render(); + await waitFor(() => expect(document.querySelector(".agent-detail-header")).toBeTruthy()); + + const desktopHeader = document.querySelector(".agent-detail-header") as HTMLElement; + const desktopIdentity = document.querySelector(".agent-detail-identity") as HTMLElement; + expect(window.getComputedStyle(desktopHeader).display).toBe("flex"); + expect(loadAllAppCssBaseOnly()).toContain("gap: var(--space-sm) var(--space-md);"); + expect(loadAllAppCssBaseOnly()).toContain("padding: var(--space-md) calc(var(--space-lg) + var(--space-xs));"); + expect(loadAllAppCssBaseOnly()).toContain(".agent-detail-identity {"); + expect(loadAllAppCssBaseOnly()).toContain("gap: var(--space-md);"); + + desktopRender.unmount(); + cleanup(); + document.head.querySelector("style[data-testid='fn-4231-css']")?.remove(); + const mobileBaseStyle = document.createElement("style"); + mobileBaseStyle.setAttribute("data-testid", "fn-4231-css"); + mobileBaseStyle.textContent = loadAllAppCss(); + document.head.appendChild(mobileBaseStyle); + appendAgentDetailMobileCssForJsdom(); + installAgentDetailMatchMedia(true); + + for (const state of ["idle", "active", "paused", "running", "error"] as const) { + mockFetchAgent.mockResolvedValueOnce(createMockAgent({ + state, + lastError: state === "error" ? "provider unavailable" : undefined, + pauseReason: state === "paused" ? "heartbeat-model-unavailable" : undefined, + })); + const { unmount } = render(); + await waitFor(() => expect(document.querySelector(".agent-detail-header")).toBeTruthy()); + + const header = document.querySelector(".agent-detail-header") as HTMLElement; + const identity = document.querySelector(".agent-detail-identity") as HTMLElement; + const badges = document.querySelector(".agent-detail-badges") as HTMLElement; + const actions = document.querySelector(".agent-detail-header-actions") as HTMLElement; + const headerStyle = window.getComputedStyle(header); + const identityStyle = window.getComputedStyle(identity); + const badgesStyle = window.getComputedStyle(badges); + + expect(headerStyle.display).toBe("grid"); + expect(headerStyle.gridTemplateColumns).toBe("minmax(0, 1fr) auto"); + expect(headerStyle.columnGap).toBe("var(--space-sm)"); + expect(headerStyle.rowGap).toBe("var(--space-sm)"); + expect(identityStyle.gap).toBe("var(--space-md)"); + expect(badgesStyle.flexWrap).toBe("wrap"); + expect(badgesStyle.columnGap).toBe("var(--space-sm)"); + expect(badgesStyle.rowGap).toBe("var(--space-xs)"); + expect(loadAllAppCss()).toContain("grid-column: 2;"); + expect(window.getComputedStyle(actions).justifyContent).toBe("flex-end"); + unmount(); + cleanup(); + } + }); + + it("adds mobile row gaps to the overview hero for long health and skills metadata (FN-7958)", async () => { + const styles = loadAllAppCss(); + expect(styles).toContain(".dashboard-summary-hero__heading {"); + expect(styles).toContain("flex-wrap: wrap;"); + expect(styles).toContain("row-gap: var(--space-lg);"); + expect(styles).toContain("@media (max-width: 480px)"); + expect(styles).toContain(".dashboard-summary-card {"); + expect(styles).toContain("padding: var(--space-md);"); + + document.head.querySelector("style[data-testid='fn-7958-mobile-css']")?.remove(); + appendAgentDetailMobileCssForJsdom(true); + installAgentDetailMatchMedia(true, true); + + for (const metadata of [{ skills: [] }, { skills: ["qa-mobile", "long-running-health-check", "visual-regression"] }]) { + mockFetchAgent.mockResolvedValueOnce(createMockAgent({ + name: "QA Engineer With A Long Mobile Header Name", + state: "paused", + pauseReason: "heartbeat-model-unavailable because the selected provider/model cannot be reached", + pendingApprovalCount: metadata.skills.length > 0 ? 3 : 0, + role: "reviewer", + runtimeConfig: { modelProvider: "kimi-coding", modelId: "moonshot-k2-mobile-regression" }, + metadata, + })); + const { unmount } = render(); + await waitFor(() => expect(document.querySelector(".dashboard-summary-hero__meta")).toBeTruthy()); + + const card = document.querySelector(".dashboard-summary-card") as HTMLElement; + const heading = document.querySelector(".dashboard-summary-hero__heading") as HTMLElement; + const meta = document.querySelector(".dashboard-summary-hero__meta") as HTMLElement; + const health = document.querySelector(".dashboard-summary-hero__health") as HTMLElement; + const cardStyle = window.getComputedStyle(card); + const headingStyle = window.getComputedStyle(heading); + const metaStyle = window.getComputedStyle(meta); + const healthStyle = window.getComputedStyle(health); + + expect(headingStyle.flexWrap).toBe("wrap"); + expect(headingStyle.rowGap).toBe("var(--space-md)"); + expect(headingStyle.columnGap).toBe("var(--space-md)"); + expect(metaStyle.flexWrap).toBe("wrap"); + expect(metaStyle.rowGap).toBe("var(--space-lg)"); + expect(metaStyle.columnGap).toBe("var(--space-sm)"); + expect(cardStyle.padding).toBe("var(--space-md)"); + expect(healthStyle.overflowWrap).toBe("anywhere"); + expect(screen.getAllByText(/Paused: heartbeat-model-unavailable/).length).toBeGreaterThanOrEqual(1); + if (metadata.skills.length > 0) { + expect(screen.getByLabelText("Assigned skills")).toBeInTheDocument(); + expect(screen.getByText("3 pending approvals")).toBeInTheDocument(); + } else { + expect(screen.getByText("Skills: —")).toBeInTheDocument(); + } + unmount(); + cleanup(); + } + }); + it("shows mobile task column context without empty task shells (FN-7139)", async () => { mockFetchAgent.mockResolvedValueOnce(createMockAgent({ taskId: "FN-MOBILE", taskColumn: "in-progress" }));