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" }));