FN-8935: make Agents Overview scrollable on mobile

Make long Active Agents lists reachable within constrained mobile and short desktop layouts.

- Establish a bounded, touch-friendly Active Agents scroll owner.
- Add unit and Chromium layout coverage for populated and empty overview states.
- Document the required browser smoke coverage and add a patch changeset.

Files changed:
 .../fn-8935-agents-overview-mobile-scroll.md       |  7 ++
 docs/testing.md                                    |  2 +
 .../__tests__/browser-layout-smoke-fixture.test.ts | 26 +++++-
 .../dashboard/app/components/AgentsOverviewBar.css | 14 ++++
 .../AgentsOverviewBar.mobile-scroll.test.tsx       | 96 ++++++++++++++++++++++
 .../dashboard/scripts/browser-layout-smoke.mjs     | 86 +++++++++++++++++++
 6 files changed, 230 insertions(+), 1 deletion(-)

Fusion-Task-Id: FN-8935

Fusion-Task-Lineage: 43b793b9-3629-45a8-b6c1-d8100467e960

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-10 03:24:37 -07:00
parent e610c72034
commit 313eea1461
6 changed files with 230 additions and 1 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Make the Agents Overview Active Agents list scrollable on mobile.
category: fix
dev: Overview bar now participates in the Agents flex height chain with a touch scroll owner so long active-agent lists are not clipped; covered by a Chromium browser-layout smoke assertion mirroring the production DOM chain.

View File

@@ -202,6 +202,8 @@ The dashboard CSS contract lane includes `app/__tests__/dashboard-css-token-vali
<!-- FNXC:CommandCenterTesting 2026-06-19-02:09: FN-6685 added a real emitted-CSS `[data-smoke="command-center-charts"]` fixture so recharts pie/line/empty states are measured in Blink at mobile and desktop breakpoints, including lazy Command Center CSS chunks that index.html does not link directly. -->
Command Center responsive chart fixes need evidence beyond jsdom. Keep the jsdom scroll-owner tests for rule/structure coverage, but pair them with `packages/dashboard/app/components/command-center/__tests__/CommandCenter.mobile-chart-layout.test.ts`, which reads the co-located Command Center CSS files directly and asserts the mobile shrink/height/border rules that real layout depends on. For visible defects, also capture a real browser/device (or headless Chrome/Blink) reproduction with `scrollWidth > clientWidth`, zero/clipped `clientHeight`, or stretch measurements; do not close a Command Center mobile chart bug on jsdom-green assertions alone. The local `pnpm --filter @fusion/dashboard test:browser-smoke --require-browser` lane now includes `[data-smoke="command-center-charts"]` and gates representative Command Center recharts pie, line, and empty states at 390×844 mobile plus desktop viewports for visible SVG/container height, overflow containment, empty-state text, and chart scroll-owner violations.
The same required-browser lane also measures the Agents Overview fixture at 390×844 mobile and 1280×700 short-desktop viewports. It verifies the real Active Agents scroll owner overflows, reaches the final card after scrolling, preserves sibling Agents content, avoids horizontal page overflow, and leaves the metrics-only empty state unclipped.
The shared mobile/tablet overflow-containment net lives at `packages/dashboard/app/__tests__/dashboard-overflow-containment.test.tsx`. It covers board/kanban columns, task-detail modal shell, workflow/simple workflow editors, and Activity Log modal at mobile, tablet, and landscape-phone breakpoints. Run it directly when touching dashboard viewport containment or shared modal/workflow CSS:
```bash

View File

@@ -121,6 +121,30 @@ describe("browser layout smoke fixture", () => {
expect(html).toContain("Plan Review needs approval");
});
it("includes the production Agents Overview scroll chain", () => {
const html = createSmokeHtml();
for (const hook of [
"agents-overview-scroll",
"show-agents-overview-scroll",
"agents-overview-scroll-owner",
"agents-overview-last-card",
"agents-overview-scroll-empty",
"agents-overview-empty-scroll-owner",
]) {
expect(html).toContain(`data-smoke="${hook}"`);
}
for (const className of [
"agents-overview-bar__content",
"agent-metrics-bar",
"active-agents-panel",
"active-agents-grid",
"live-agent-card",
]) {
expect(html).toContain(className);
}
expect(html.match(/class="live-agent-card"/g)).toHaveLength(13);
});
it("includes PR flow fixture sections and class hooks", () => {
const html = createSmokeHtml();
expect(html).toContain('data-smoke="pr-create-modal"');
@@ -158,7 +182,7 @@ describe("browser layout smoke fixture", () => {
expect(html).toContain('data-smoke="quick-add-save-row"');
expect(html).toContain('data-smoke="quick-add-save-button"');
expect(html).toContain('data-testid="quick-entry-session-advisor-toggle"');
expect(html.match(/data-testid="quick-entry-(?:attach|github-toggle|session-advisor-toggle|priority-button|fast-toggle)"/g)).toHaveLength(120);
expect(html.match(/data-testid="quick-entry-(?:attach|github-toggle|session-advisor-toggle|priority-button|fast-toggle)"/g)).toHaveLength(140);
for (const label of ["Save", "Guardar", "Enregistrer", "저장", "保存", "儲存"]) {
expect(html).toContain(label);
}

View File

@@ -1,14 +1,23 @@
/*
FNXC:AgentsOverviewScroll 2026-08-10-10:02:
Overview owns vertical scrolling when Active Agents exceeds the constrained Agents flex shell, so mobile and short viewports can reach every card without restoring document scrolling. The overview itself clips its flex child while the content scrollport contains overscroll before nested card transcripts can hand it to the page.
*/
.agents-overview-bar {
border-bottom: 1px solid var(--border);
background: var(--surface);
padding: var(--space-md) var(--space-lg);
display: flex;
flex: 0 1 auto;
flex-direction: column;
min-height: 0;
max-height: 70%;
gap: var(--space-sm);
overflow: hidden;
}
.agents-overview-bar__toggle {
width: 100%;
flex-shrink: 0;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
@@ -47,8 +56,13 @@
.agents-overview-bar__content {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
gap: var(--space-md);
overflow-y: auto;
-webkit-overflow-scrolling: touch;
overscroll-behavior: contain;
}
.agents-overview-bar__metrics {

View File

@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render } from "@testing-library/react";
import type { Agent } from "../../api";
import { loadAllAppCss } from "../../test/cssFixture";
vi.mock("../../hooks/useLiveTranscript", () => ({
useLiveTranscript: () => ({ entries: [], isConnected: false }),
}));
vi.mock("../../hooks/useAgentActivity", () => ({
useAgentActivity: () => ({ activityByAgentId: new Map(), events: [], nowTick: 0 }),
}));
vi.mock("../RuntimeFallbackBadge", () => ({ RuntimeFallbackBadge: () => null }));
vi.mock("../AgentTaskBadge", () => ({ AgentTaskBadge: () => null }));
import { AgentsOverviewBar } from "../AgentsOverviewBar";
function makeAgent(id: string): Agent {
return {
id,
name: `Agent ${id}`,
role: "executor",
state: "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
metadata: {},
};
}
const css = loadAllAppCss();
function rule(selector: string): string {
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
return css.match(new RegExp(`${escaped}\\s*\\{([^}]*)\\}`))?.[1] ?? "";
}
describe("AgentsOverviewBar mobile scroll contract", () => {
it("keeps the overview content as the constrained touch scroll owner", () => {
const overview = rule(".agents-overview-bar");
const content = rule(".agents-overview-bar__content");
expect(overview).toContain("min-height: 0");
expect(overview).toContain("overflow: hidden");
expect(content).toContain("min-height: 0");
expect(content).toContain("overflow-y: auto");
expect(content).toContain("-webkit-overflow-scrolling: touch");
expect(content).toContain("overscroll-behavior: contain");
expect(rule(".agents-overview-bar__toggle")).toContain("flex-shrink: 0");
expect(rule(".agents-view-content")).toContain("flex: 1");
expect(rule(".agents-view-content")).toContain("min-height: 0");
expect(rule(".agents-view-content")).toContain("overflow-y: auto");
expect(rule(".agent-org-chart-viewport")).toContain("overflow: hidden");
expect(rule(".agent-org-chart-viewport")).toContain("overscroll-behavior: contain");
});
it("renders the production overview chain for many, duplicate, and empty active-agent states", () => {
const agents = Array.from({ length: 13 }, (_, index) => makeAgent(`agent-${index}`));
const onToggle = vi.fn();
const { container, rerender } = render(
<div className="agents-view">
<AgentsOverviewBar stats={{ activeCount: 13, assignedTaskCount: 13, completedRuns: 0, failedRuns: 0, successRate: 1 }} activeAgents={[...agents, agents[0]]} isOpen onToggle={onToggle} />
</div>,
);
const overview = container.querySelector(".agents-view > section.agents-overview-bar");
const toggle = overview?.querySelector(":scope > button.agents-overview-bar__toggle");
const content = overview?.querySelector(":scope > .agents-overview-bar__content");
expect(overview).toBeTruthy();
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(content?.firstElementChild).toHaveClass("agent-metrics-bar", "agents-overview-bar__metrics");
const panel = content?.querySelector(":scope > .active-agents-panel.agents-overview-bar__active-panel");
expect(panel?.querySelector(":scope > .active-agents-panel-header")).toBeTruthy();
expect(panel?.querySelector(":scope > .active-agents-grid")).toBeTruthy();
expect(panel?.querySelectorAll(":scope > .active-agents-grid > .live-agent-card")).toHaveLength(13);
fireEvent.click(toggle!);
expect(onToggle).toHaveBeenCalledOnce();
rerender(
<div className="agents-view">
<AgentsOverviewBar stats={{ activeCount: 0, assignedTaskCount: 0, completedRuns: 0, failedRuns: 0, successRate: 1 }} activeAgents={[]} isOpen onToggle={onToggle} />
</div>,
);
const emptyContent = container.querySelector(".agents-overview-bar__content");
expect(emptyContent?.querySelector(".agent-metrics-bar.agents-overview-bar__metrics")).toBeTruthy();
expect(emptyContent?.querySelector(".active-agents-panel")).toBeNull();
rerender(
<div className="agents-view">
<AgentsOverviewBar stats={{ activeCount: 13, assignedTaskCount: 13, completedRuns: 0, failedRuns: 0, successRate: 1 }} activeAgents={agents} isOpen={false} onToggle={onToggle} />
</div>,
);
expect(container.querySelector(".agents-overview-bar__content")).toBeNull();
});
});

View File

@@ -131,6 +131,12 @@ export function createSmokeHtml() {
</section>
`)
.join("");
const agentsOverviewCards = Array.from({ length: 13 }, (_, index) => `
<div class="live-agent-card" role="button" tabindex="0"${index === 12 ? ' data-smoke="agents-overview-last-card"' : ""}>
<div class="live-agent-card-header"><span class="live-agent-card-name">Active agent ${index + 1}</span></div>
<div class="live-agent-card-transcript">Waiting for workflow output and heartbeat activity.</div>
<div class="live-agent-card-footer">Active</div>
</div>`).join("");
/*
FNXC:QuickAddActionRow 2026-07-17-12:00:
@@ -398,6 +404,7 @@ export function createSmokeHtml() {
<button class="btn-icon" data-smoke="show-command-center-charts" type="button" aria-label="Show Command Center charts fixture">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 19V5"></path><path d="M4 19h16"></path><path d="M8 15l3-4 3 2 4-6"></path></svg>
</button>
<button class="btn-icon" data-smoke="show-agents-overview-scroll" type="button" aria-label="Show Agents overview scroll fixture">Agents overview</button>
</div>
</header>
</div>
@@ -627,6 +634,26 @@ export function createSmokeHtml() {
</section>
</section>
<!--
FNXC:AgentsOverviewScroll 2026-08-10-10:02:
Real Blink must verify this flex scroll chain because jsdom has no layout engine. This fixture mirrors the production chain asserted by AgentsOverviewBar.mobile-scroll.test.tsx, including metrics, panel, grid, and realistic live-agent cards.
-->
<section data-smoke="agents-overview-scroll" hidden>
<section class="agents-view" aria-label="Agents overview scroll fixture" style="height: min(calc(var(--space-2xl) * 16), calc(100dvh - var(--space-2xl)));">
<section class="agents-overview-bar" aria-label="Agents overview">
<button class="agents-overview-bar__toggle" type="button" aria-expanded="true"><span class="agents-overview-bar__title-wrap"><span class="agents-overview-bar__title">Overview</span></span><span class="agents-overview-bar__meta text-secondary">13 active</span></button>
<div class="agents-overview-bar__content" data-smoke="agents-overview-scroll-owner">
<div class="agent-metrics-bar agents-overview-bar__metrics"><div class="agent-metric-card agent-metric-card--active">Active</div><div class="agent-metric-card agent-metric-card--tasks">Tasks</div><div class="agent-metric-card agent-metric-card--success">Success</div><div class="agent-metric-card agent-metric-card--runs">Runs</div></div>
<div class="active-agents-panel agents-overview-bar__active-panel"><div class="active-agents-panel-header">Active Agents (13)</div><div class="active-agents-grid">${agentsOverviewCards}</div></div>
</div>
</section>
<div class="agents-view-content">Sibling Agents content</div>
</section>
<section class="agents-view" data-smoke="agents-overview-scroll-empty" style="height: min(calc(var(--space-2xl) * 16), calc(100dvh - var(--space-2xl)));">
<section class="agents-overview-bar" aria-label="Empty Agents overview"><button class="agents-overview-bar__toggle" type="button" aria-expanded="true">Overview</button><div class="agents-overview-bar__content" data-smoke="agents-overview-empty-scroll-owner"><div class="agent-metrics-bar agents-overview-bar__metrics"><div class="agent-metric-card agent-metric-card--active">Active</div><div class="agent-metric-card agent-metric-card--tasks">Tasks</div><div class="agent-metric-card agent-metric-card--success">Success</div><div class="agent-metric-card agent-metric-card--runs">Runs</div></div></div></section>
</section>
</section>
<!--
FNXC:CommandCenterTesting 2026-06-19-02:04:
FN-6685 requires a real-Blink desktop and mobile gate for the FN-6683/FN-6684 recharts surfaces because jsdom cannot compute ResponsiveContainer parent height, min-content shrink, or overflow. This fixture mirrors Command Center tabpanel/card wrappers and includes populated pie/line plus empty states so emitted dashboard CSS owns the sizing chain under test.
@@ -743,6 +770,7 @@ export function createSmokeHtml() {
const prPanel = document.querySelector('[data-smoke="pr-panel"]');
const prChecks = document.querySelector('[data-smoke="pr-checks"]');
const commandCenterCharts = document.querySelector('[data-smoke="command-center-charts"]');
const agentsOverviewScroll = document.querySelector('[data-smoke="agents-overview-scroll"]');
function setView(view) {
const isList = view === 'list';
@@ -757,6 +785,7 @@ export function createSmokeHtml() {
prPanel.hidden = name !== 'pr-panel';
prChecks.hidden = name !== 'pr-checks';
commandCenterCharts.hidden = name !== 'command-center-charts';
agentsOverviewScroll.hidden = name !== 'agents-overview-scroll';
}
boardButton.addEventListener('click', () => setView('board'));
@@ -765,6 +794,7 @@ export function createSmokeHtml() {
document.querySelector('[data-smoke="show-pr-panel"]').addEventListener('click', () => showSmokeSection('pr-panel'));
document.querySelector('[data-smoke="show-pr-checks"]').addEventListener('click', () => showSmokeSection('pr-checks'));
document.querySelector('[data-smoke="show-command-center-charts"]').addEventListener('click', () => showSmokeSection('command-center-charts'));
document.querySelector('[data-smoke="show-agents-overview-scroll"]').addEventListener('click', () => showSmokeSection('agents-overview-scroll'));
document.querySelector('[data-smoke="open-modal"]').addEventListener('click', () => {
modalOverlay.classList.add('open');
nav.hidden = true;
@@ -1242,6 +1272,62 @@ async function runSmokeChecks(page, pageUrl) {
&& layout.badgeReason === "plan-review-replan-cap"
&& layout.bannerReason === "plan-review-replan-cap";
const collectAgentsOverviewScrollLayout = () => evaluate(page, `(() => {
document.querySelector('[data-smoke="show-agents-overview-scroll"]').click();
const section = document.querySelector('[data-smoke="agents-overview-scroll"]');
const fixture = section.querySelector('.agents-view');
const owner = section.querySelector('[data-smoke="agents-overview-scroll-owner"]');
const lastCard = section.querySelector('[data-smoke="agents-overview-last-card"]');
const sibling = fixture.querySelector('.agents-view-content');
const empty = section.querySelector('[data-smoke="agents-overview-scroll-empty"]');
const emptyOwner = section.querySelector('[data-smoke="agents-overview-empty-scroll-owner"]');
owner.scrollTop = owner.scrollHeight - owner.clientHeight;
const ownerRect = owner.getBoundingClientRect();
const lastRect = lastCard.getBoundingClientRect();
const toggleRect = empty.querySelector('.agents-overview-bar__toggle').getBoundingClientRect();
const emptyRect = empty.getBoundingClientRect();
return {
overflowY: getComputedStyle(owner).overflowY,
overflow: owner.scrollHeight - owner.clientHeight,
lastCard: { top: lastRect.top, bottom: lastRect.bottom },
owner: { top: ownerRect.top, bottom: ownerRect.bottom },
siblingHeight: sibling.clientHeight,
documentOverflow: document.documentElement.scrollWidth - window.innerWidth,
emptyOverflow: emptyOwner.scrollHeight - emptyOwner.clientHeight,
emptyToggleVisible: toggleRect.top >= emptyRect.top - 1 && toggleRect.bottom <= emptyRect.bottom + 1,
};
})()`);
for (const { width, height, deviceScaleFactor, mobile } of [
{ width: 390, height: 844, deviceScaleFactor: 2, mobile: true },
{ width: 1280, height: 700, deviceScaleFactor: 1, mobile: false },
]) {
await page.send("Emulation.setDeviceMetricsOverride", { width, height, deviceScaleFactor, mobile });
const agentsOverviewLayout = await collectAgentsOverviewScrollLayout();
const viewport = `${width}×${height}`;
assertSmokeResult(
`Agents Overview scroll owner reaches every active card at ${viewport}`,
(agentsOverviewLayout.overflowY === "auto" || agentsOverviewLayout.overflowY === "scroll")
&& agentsOverviewLayout.overflow > 0
&& agentsOverviewLayout.lastCard.bottom <= agentsOverviewLayout.owner.bottom + 1
&& agentsOverviewLayout.lastCard.top >= agentsOverviewLayout.owner.top - 1
&& agentsOverviewLayout.siblingHeight > 0
&& agentsOverviewLayout.documentOverflow <= 1,
JSON.stringify(agentsOverviewLayout),
);
assertSmokeResult(
`Agents Overview empty scroll owner stays unclipped at ${viewport}`,
agentsOverviewLayout.emptyOverflow <= 1 && agentsOverviewLayout.emptyToggleVisible,
JSON.stringify(agentsOverviewLayout),
);
}
await page.send("Emulation.setDeviceMetricsOverride", {
width: 390,
height: 844,
deviceScaleFactor: 2,
mobile: true,
});
const mobileResolvedGithubTableLayout = await collectResolvedGithubTableLayout();
assertSmokeResult(
"resolved GitHub table wraps long content without mobile page overflow",