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:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {");
|
||||
|
||||
@@ -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" }));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user