feat(FN-3377): compress mobile agent detail header

- Rework AgentDetailView mobile header structure to reduce vertical space while preserving key metadata
- Simplify AgentsView mobile spacing and remove redundant styles tied to legacy header layout
- Update dashboard CSS class coverage tests for new agent detail/mobile header classes
- Expand AgentDetailView and AgentsView tests to validate condensed header rendering and interaction behavior

Fusion-Task-Id: FN-3377
This commit is contained in:
Fusion
2026-05-04 10:37:59 -07:00
committed by gsxdsm
parent d30f8a7a3b
commit ceea0387a7
7 changed files with 81 additions and 36 deletions

View File

@@ -156,6 +156,11 @@ describe("Agent CSS classes", () => {
expect(viewContent).toContain("min-height: 0");
});
it("removes legacy standalone mobile back-row classes from AgentsView", () => {
expect(hasClass(".agents-mobile-back-row")).toBe(false);
expect(hasClass(".agents-mobile-back-btn")).toBe(false);
});
it("should visually group the filter controls", () => {
const filtersBlock = extractRuleBlock(".agent-controls-filters");
expect(filtersBlock).toContain("padding: var(--space-xs) var(--space-sm)");
@@ -196,6 +201,7 @@ describe("Agent CSS classes", () => {
expect(hasClass(".agent-detail-actions")).toBe(true);
// Redesigned compact header structure
expect(hasClass(".agent-detail-identity")).toBe(true);
expect(hasClass(".agent-detail-inline-back")).toBe(true);
expect(hasClass(".agent-detail-controls")).toBe(true);
expect(hasClass(".agent-detail-utility-actions")).toBe(true);
expect(hasClass(".agent-detail-tabs")).toBe(true);

View File

@@ -94,6 +94,15 @@
min-width: 0;
}
.agent-detail-inline-back {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
min-height: calc(var(--space-lg) + var(--space-md) + var(--space-xs));
padding: calc(var(--space-sm) - var(--space-xs) * 0.5) calc(var(--space-sm) + var(--space-xs) * 0.5);
flex-shrink: 0;
}
.agent-detail-icon {
width: calc(var(--space-lg) * 2 + var(--space-xs));
height: calc(var(--space-lg) * 2 + var(--space-xs));
@@ -1304,13 +1313,16 @@
}
.agent-detail-header {
flex-wrap: wrap;
gap: var(--space-sm);
padding: var(--space-md);
padding-top: max(var(--space-md), env(safe-area-inset-top, 0));
display: grid;
grid-template-columns: minmax(0, 1fr);
grid-template-rows: auto auto;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
padding-top: max(var(--space-sm), env(safe-area-inset-top, 0));
}
.agent-detail-identity {
grid-row: 1;
flex: 1 1 auto;
min-width: 0;
gap: var(--space-sm);
@@ -1339,17 +1351,16 @@
}
.agent-detail-header-actions {
flex: 1 1 100%;
flex-wrap: wrap;
grid-row: 2;
flex: 1 1 auto;
justify-content: space-between;
gap: var(--space-sm);
order: 3;
gap: var(--space-xs);
}
.agent-detail-controls {
flex: 1 1 auto;
flex-wrap: wrap;
gap: calc(var(--space-xs) + var(--space-sm) * 0.25);
gap: var(--space-xs);
}
.agent-detail-controls .btn--compact {

View File

@@ -4,7 +4,7 @@ import {
Bot, Heart, Activity, Pause, Play, Square, Trash2, RefreshCw,
Settings, FileText, ActivitySquare, X, Copy,
ExternalLink, CheckCircle, XCircle, Loader2, GitBranch, ListChecks,
ChevronDown, ChevronRight, BarChart3, BookOpen, Eye, FileEdit
ChevronDown, ChevronRight, ChevronLeft, BarChart3, BookOpen, Eye, FileEdit
} from "lucide-react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
@@ -62,6 +62,7 @@ interface AgentDetailViewProps {
addToast: (message: string, type?: "success" | "error") => void;
onChildClick?: (childId: string) => void;
inline?: boolean;
showInlineBackButton?: boolean;
initialTab?: TabId;
initialRunId?: string | null;
preferActiveRun?: boolean;
@@ -122,7 +123,7 @@ function pickDefaultAgentMemoryPath(files: MemoryFileInfo[], currentPath: string
?? "";
}
export function AgentDetailView({ agentId, projectId, onClose, addToast, onChildClick, inline = false, initialTab, initialRunId, preferActiveRun = false }: AgentDetailViewProps) {
export function AgentDetailView({ agentId, projectId, onClose, addToast, onChildClick, inline = false, showInlineBackButton = false, initialTab, initialRunId, preferActiveRun = false }: AgentDetailViewProps) {
const [agent, setAgent] = useState<AgentDetail | null>(null);
const { confirm } = useConfirm();
const [logs, setLogs] = useState<AgentLogEntry[]>([]);
@@ -493,6 +494,17 @@ export function AgentDetailView({ agentId, projectId, onClose, addToast, onChild
<div className="agent-detail-header">
{/* Identity area: icon + name + badges */}
<div className="agent-detail-identity">
{inline && showInlineBackButton ? (
<button
type="button"
className="btn agent-detail-inline-back"
onClick={onClose}
aria-label="Back to agents"
>
<ChevronLeft size={16} />
Agents
</button>
) : null}
<div className="agent-detail-icon">
<Bot size={20} />
</div>

View File

@@ -1178,16 +1178,6 @@
display: none;
}
.agents-mobile-back-row {
padding: var(--space-md);
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.agents-mobile-back-btn {
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
}
.agents-view-content {
min-height: 0;
overflow-y: auto;

View File

@@ -1,6 +1,6 @@
import "./AgentsView.css";
import { useState, useEffect, useCallback, useRef, useMemo, useId, lazy, Suspense } from "react";
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, ChevronLeft, ChevronDown, ChevronUp, Filter, Upload, Network, SlidersHorizontal, Copy, Check } from "lucide-react";
import { Plus, Play, Pause, Activity, Trash2, RefreshCw, Bot, List, ChevronRight, ChevronDown, ChevronUp, Filter, Upload, Network, SlidersHorizontal, Copy, Check } from "lucide-react";
import type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api";
import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api";
@@ -1409,18 +1409,11 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
</div>
<div className={`agents-split-detail${isMobileViewport && !selectedAgentId ? " agents-split-detail--hidden-mobile" : ""}`}>
{isMobileDetailOpen && (
<div className="agents-mobile-back-row">
<button className="btn agents-mobile-back-btn" onClick={handleCloseDetail}>
<ChevronLeft size={16} />
Agents
</button>
</div>
)}
{selectedAgentId ? (
<Suspense fallback={null}>
<AgentDetailView
inline
showInlineBackButton={isMobileViewport}
agentId={selectedAgentId}
projectId={projectId}
onClose={handleCloseDetail}

View File

@@ -276,9 +276,33 @@ describe("AgentDetailView", () => {
expect(document.querySelector(".agent-detail-overlay")).toBeNull();
expect(screen.queryByRole("button", { name: "Close" })).toBeNull();
expect(screen.queryByRole("button", { name: "Back to agents" })).toBeNull();
expect(screen.getByRole("heading", { name: "Test Agent" })).toBeInTheDocument();
});
it("renders inline mobile back affordance inside detail header when enabled", async () => {
const onClose = vi.fn();
render(
<AgentDetailView
agentId="agent-001"
onClose={onClose}
addToast={vi.fn()}
inline
showInlineBackButton
/>,
);
await waitFor(() => {
expect(screen.getByLabelText("Back to agents")).toBeInTheDocument();
});
await userEvent.click(screen.getByLabelText("Back to agents"));
expect(onClose).toHaveBeenCalledTimes(1);
const identityContainer = document.querySelector(".agent-detail-identity");
expect(identityContainer?.querySelector(".agent-detail-inline-back")).toBeTruthy();
});
it("keeps modal mode as dialog with close button", async () => {
render(
<AgentDetailView
@@ -848,14 +872,20 @@ describe("AgentDetailView", () => {
});
});
it("keeps desktop header actions on one row and mobile wraps them safely", () => {
it("keeps desktop header actions on one row and mobile uses explicit two-row header contracts", () => {
const stylesContent = loadAllAppCss();
expect(stylesContent).toContain(".agent-detail-header-actions {");
expect(stylesContent).toContain("justify-content: flex-end;");
expect(stylesContent).toContain(".agent-detail-inline-back {");
const mobileHeaderActionsBlock = /@media \(max-width: 768px\)\s*\{[\s\S]*?\.agent-detail-header-actions\s*\{[\s\S]*?flex-wrap: wrap;[\s\S]*?\}/;
expect(stylesContent).toMatch(mobileHeaderActionsBlock);
expect(stylesContent).toContain("@media (max-width: 768px)");
expect(stylesContent).toContain(".agent-detail-header {");
expect(stylesContent).toContain("grid-template-rows: auto auto;");
expect(stylesContent).toContain(".agent-detail-identity {");
expect(stylesContent).toContain("grid-row: 1;");
expect(stylesContent).toContain(".agent-detail-header-actions {");
expect(stylesContent).toContain("grid-row: 2;");
});
it("shows statistics section on dashboard", async () => {

View File

@@ -31,8 +31,11 @@ vi.mock("../../api", async (importOriginal) => {
});
vi.mock("../AgentDetailView", () => ({
AgentDetailView: ({ agentId, inline, initialTab, initialRunId, preferActiveRun }: { agentId: string; inline?: boolean; initialTab?: string; initialRunId?: string | null; preferActiveRun?: boolean }) => (
AgentDetailView: ({ agentId, inline, onClose, showInlineBackButton, initialTab, initialRunId, preferActiveRun }: { agentId: string; inline?: boolean; onClose?: () => void; showInlineBackButton?: boolean; initialTab?: string; initialRunId?: string | null; preferActiveRun?: boolean }) => (
<div data-testid="agent-detail-view" data-inline={inline ? "true" : "false"} data-initial-tab={initialTab ?? "dashboard"} data-initial-run-id={initialRunId ?? ""} data-prefer-active-run={preferActiveRun ? "true" : "false"}>
{showInlineBackButton ? (
<button type="button" aria-label="Back to agents" onClick={onClose}>Agents</button>
) : null}
Agent detail: {agentId}
</div>
),
@@ -325,14 +328,14 @@ describe("AgentsView", () => {
fireEvent.click(await screen.findByRole("button", { name: "View details for Test Agent 1" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Agents" })).toBeTruthy();
expect(screen.getByRole("button", { name: "Back to agents" })).toBeTruthy();
expect(screen.getByTestId("agent-detail-view")).toHaveAttribute("data-inline", "true");
});
expect(container.querySelector(".agents-split-sidebar--hidden-mobile")).toBeTruthy();
expect(container.querySelector(".agents-split-detail--hidden-mobile")).toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Agents" }));
fireEvent.click(screen.getByRole("button", { name: "Back to agents" }));
await waitFor(() => {
expect(screen.getByText("Select an agent")).toBeInTheDocument();