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) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-15 12:12:53 -07:00
parent d956abceee
commit 37db66965c
3 changed files with 230 additions and 7 deletions

View File

@@ -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;
}

View File

@@ -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 {");

View File

@@ -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(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} inline />);
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(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} inline />);
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(<AgentDetailView agentId="agent-001" onClose={vi.fn()} addToast={vi.fn()} inline />);
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" }));