feat(FN-3224): update agents layout documentation

The merge lands a documentation update to `docs/agents.md`, correcting or refining the agents layout section (2 lines changed). No code or behavior changes.

Fusion-Task-Id: FN-3224
This commit is contained in:
Fusion
2026-05-03 04:38:39 -07:00
committed by gsxdsm
parent 630d615ae4
commit eafb078804
10 changed files with 208 additions and 37 deletions

View File

@@ -86,10 +86,10 @@ These fields can only be set during update (not on create):
The agents surface provides: The agents surface provides:
- Agent-first list/board/tree/org collection (primary content appears first) - Agent-first list/board/tree/org collection in the left pane (primary content appears first)
- A cross-pane **Overview** strip above the split layout with summary metrics and a disclosure to expand active/running live cards
- A compact **Controls** popup for secondary actions (state filter, Show system agents toggle, Import, and global Heartbeat Speed) - A compact **Controls** popup for secondary actions (state filter, Show system agents toggle, Import, and global Heartbeat Speed)
- Detail/config panels - Detail/config panels
- Runtime metrics and active-agent live cards rendered below the main collection
- A per-agent **Token Usage** panel that summarizes cumulative token consumption for the currently displayed agents - A per-agent **Token Usage** panel that summarizes cumulative token consumption for the currently displayed agents
- Run history - Run history
- Task assignment context - Task assignment context

View File

@@ -83,6 +83,9 @@ describe("Agent CSS classes", () => {
expect(hasClass(".agents-view-controls")).toBe(true); expect(hasClass(".agents-view-controls")).toBe(true);
expect(hasClass(".agents-view-primary-actions")).toBe(true); expect(hasClass(".agents-view-primary-actions")).toBe(true);
expect(hasClass(".agents-view-content")).toBe(true); expect(hasClass(".agents-view-content")).toBe(true);
expect(hasClass(".agents-overview-bar")).toBe(true);
expect(hasClass(".agents-overview-bar__toggle")).toBe(true);
expect(hasClass(".agents-overview-bar__content")).toBe(true);
expect(hasClass(".agent-controls-trigger")).toBe(true); expect(hasClass(".agent-controls-trigger")).toBe(true);
expect(hasClass(".agent-controls-trigger--active")).toBe(true); expect(hasClass(".agent-controls-trigger--active")).toBe(true);
expect(hasClass(".agent-controls-panel")).toBe(true); expect(hasClass(".agent-controls-panel")).toBe(true);
@@ -142,6 +145,17 @@ describe("Agent CSS classes", () => {
expect(hasClass(".spin")).toBe(true); expect(hasClass(".spin")).toBe(true);
}); });
it("keeps split layout as the scroll-constrained pane container", () => {
const splitLayout = extractRuleBlock(".agents-split-layout");
expect(splitLayout).toContain("flex: 1");
expect(splitLayout).toContain("min-height: 0");
expect(splitLayout).not.toContain("height: 100%");
const viewContent = extractRuleBlock(".agents-view-content");
expect(viewContent).toContain("overflow-y: auto");
expect(viewContent).toContain("min-height: 0");
});
it("should visually group the filter controls", () => { it("should visually group the filter controls", () => {
const filtersBlock = extractRuleBlock(".agent-controls-filters"); const filtersBlock = extractRuleBlock(".agent-controls-filters");
expect(filtersBlock).toContain("padding: var(--space-xs) var(--space-sm)"); expect(filtersBlock).toContain("padding: var(--space-xs) var(--space-sm)");
@@ -310,7 +324,7 @@ describe("Agent CSS classes", () => {
expect(hasClass(".live-agent-card")).toBe(true); expect(hasClass(".live-agent-card")).toBe(true);
expect(hasClass(".live-agent-card-header")).toBe(true); expect(hasClass(".live-agent-card-header")).toBe(true);
expect(hasClass(".live-agent-card-name")).toBe(true); expect(hasClass(".live-agent-card-name")).toBe(true);
expect(hasClass(".live-agent-pulse")).toBe(true); expect(hasClass(".status-dot")).toBe(true);
expect(hasClass(".live-agent-task")).toBe(true); expect(hasClass(".live-agent-task")).toBe(true);
expect(hasClass(".live-agent-card-transcript")).toBe(true); expect(hasClass(".live-agent-card-transcript")).toBe(true);
expect(hasClass(".live-agent-card-empty")).toBe(true); expect(hasClass(".live-agent-card-empty")).toBe(true);

View File

@@ -57,14 +57,6 @@
font-size: 13px; font-size: 13px;
} }
.live-agent-pulse {
width: 8px;
height: 8px;
background: var(--color-success);
border-radius: 50%;
animation: pulse 1.5s infinite;
}
.live-agent-task { .live-agent-task {
font-family: var(--font-mono); font-family: var(--font-mono);
} }

View File

@@ -104,7 +104,10 @@ function LiveAgentCard({ agent, projectId, onSelect, onOpenTaskLogs }: LiveAgent
> >
<div className="live-agent-card-header"> <div className="live-agent-card-header">
<div className="live-agent-card-name"> <div className="live-agent-card-name">
<span className="live-agent-pulse" /> <span
className={`status-dot ${agent.state === "running" ? "status-dot--pending" : "status-dot--online"}`}
aria-hidden="true"
/>
<span>{agent.name}</span> <span>{agent.name}</span>
</div> </div>
{agent.taskId && ( {agent.taskId && (
@@ -189,9 +192,10 @@ interface ActiveAgentsPanelProps {
projectId?: string; projectId?: string;
onAgentSelect?: (agentId: string) => void; onAgentSelect?: (agentId: string) => void;
onOpenTaskLogs?: (taskId: string) => void; onOpenTaskLogs?: (taskId: string) => void;
className?: string;
} }
export function ActiveAgentsPanel({ agents, projectId, onAgentSelect, onOpenTaskLogs }: ActiveAgentsPanelProps) { export function ActiveAgentsPanel({ agents, projectId, onAgentSelect, onOpenTaskLogs, className = "" }: ActiveAgentsPanelProps) {
// Dedupe by id defensively. The store should return unique agents but a race // Dedupe by id defensively. The store should return unique agents but a race
// between the initial fetch and an SSE refresh can briefly surface the same // between the initial fetch and an SSE refresh can briefly surface the same
// agent twice — without this guard React floods the console with duplicate // agent twice — without this guard React floods the console with duplicate
@@ -201,7 +205,7 @@ export function ActiveAgentsPanel({ agents, projectId, onAgentSelect, onOpenTask
if (uniqueAgents.length === 0) return null; if (uniqueAgents.length === 0) return null;
return ( return (
<div className="active-agents-panel"> <div className={`active-agents-panel ${className}`.trim()}>
<div className="active-agents-panel-header"> <div className="active-agents-panel-header">
<Activity size={16} /> <Activity size={16} />
<span>Active Agents ({uniqueAgents.length})</span> <span>Active Agents ({uniqueAgents.length})</span>

View File

@@ -3,6 +3,7 @@ import type { AgentStats } from "../api";
interface AgentMetricsBarProps { interface AgentMetricsBarProps {
stats: AgentStats | null; stats: AgentStats | null;
className?: string;
} }
const METRIC_CARDS = [ const METRIC_CARDS = [
@@ -12,11 +13,11 @@ const METRIC_CARDS = [
{ icon: Zap, label: "Total Runs", valueKey: "completedRuns", className: "agent-metric-card--runs" }, { icon: Zap, label: "Total Runs", valueKey: "completedRuns", className: "agent-metric-card--runs" },
] as const; ] as const;
export function AgentMetricsBar({ stats }: AgentMetricsBarProps) { export function AgentMetricsBar({ stats, className = "" }: AgentMetricsBarProps) {
if (!stats) return null; if (!stats) return null;
return ( return (
<div className="agent-metrics-bar"> <div className={`agent-metrics-bar ${className}`.trim()}>
{METRIC_CARDS.map((card) => { {METRIC_CARDS.map((card) => {
const value = card.valueKey === "successRate" const value = card.valueKey === "successRate"
? `${Math.round(stats.successRate * 100)}%` ? `${Math.round(stats.successRate * 100)}%`

View File

@@ -0,0 +1,71 @@
.agents-overview-bar {
border-bottom: 1px solid var(--border);
background: var(--surface);
padding: var(--space-md) var(--space-lg);
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.agents-overview-bar__toggle {
width: 100%;
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--card);
color: var(--text);
padding: var(--space-sm) var(--space-md);
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
cursor: pointer;
transition: background var(--transition-fast), border-color var(--transition-fast);
}
.agents-overview-bar__toggle:hover {
background: var(--card-hover);
}
.agents-overview-bar__toggle:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.agents-overview-bar__title-wrap {
display: inline-flex;
align-items: center;
gap: var(--space-xs);
}
.agents-overview-bar__title {
font-weight: 600;
}
.agents-overview-bar__meta {
font-size: calc(var(--space-sm) + var(--space-xs));
}
.agents-overview-bar__content {
display: flex;
flex-direction: column;
gap: var(--space-md);
}
.agents-overview-bar__metrics {
margin-bottom: 0;
}
.agents-overview-bar__active-panel {
margin-top: 0;
margin-bottom: 0;
}
@media (max-width: 768px) {
.agents-overview-bar {
padding: var(--space-sm) var(--space-md);
}
.agents-overview-bar__toggle {
min-height: calc(var(--space-2xl) + var(--space-sm));
}
}

View File

@@ -0,0 +1,56 @@
import { ChevronDown, ChevronRight } from "lucide-react";
import { AgentMetricsBar } from "./AgentMetricsBar";
import { ActiveAgentsPanel } from "./ActiveAgentsPanel";
import type { Agent, AgentStats } from "../api";
import "./AgentsOverviewBar.css";
interface AgentsOverviewBarProps {
stats: AgentStats | null;
activeAgents: Agent[];
projectId?: string;
isOpen: boolean;
onToggle: () => void;
onSelectAgent?: (agentId: string) => void;
onOpenTaskLogs?: (taskId: string) => void;
}
export function AgentsOverviewBar({
stats,
activeAgents,
projectId,
isOpen,
onToggle,
onSelectAgent,
onOpenTaskLogs,
}: AgentsOverviewBarProps) {
return (
<section className="agents-overview-bar" aria-label="Agents overview">
<button
type="button"
className="agents-overview-bar__toggle"
aria-expanded={isOpen}
onClick={onToggle}
>
<span className="agents-overview-bar__title-wrap">
{isOpen ? <ChevronDown size={16} aria-hidden="true" /> : <ChevronRight size={16} aria-hidden="true" />}
<span className="agents-overview-bar__title">Overview</span>
</span>
<span className="agents-overview-bar__meta text-secondary">
{stats?.activeCount ?? 0} active · {activeAgents.length} running
</span>
</button>
{isOpen ? (
<div className="agents-overview-bar__content">
<AgentMetricsBar stats={stats} className="agents-overview-bar__metrics" />
<ActiveAgentsPanel
agents={activeAgents}
projectId={projectId}
onAgentSelect={onSelectAgent}
onOpenTaskLogs={onOpenTaskLogs}
className="agents-overview-bar__active-panel"
/>
</div>
) : null}
</section>
);
}

View File

@@ -137,7 +137,7 @@
display: grid; display: grid;
grid-template-columns: minmax(calc(var(--space-xl) * 11 + var(--space-xs)), calc(var(--space-xl) * 13 + var(--space-lg))) minmax(0, 1fr); grid-template-columns: minmax(calc(var(--space-xl) * 11 + var(--space-xs)), calc(var(--space-xl) * 13 + var(--space-lg))) minmax(0, 1fr);
gap: 0; gap: 0;
height: 100%; flex: 1;
min-height: 0; min-height: 0;
} }
@@ -164,6 +164,7 @@
padding: calc(var(--space-lg) + var(--space-xs)); padding: calc(var(--space-lg) + var(--space-xs));
} }
.agents-detail-empty-state { .agents-detail-empty-state {
display: flex; display: flex;
flex: 1; flex: 1;

View File

@@ -5,9 +5,8 @@ import type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTre
import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api"; import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api";
const AgentDetailView = lazy(() => import("./AgentDetailView").then((m) => ({ default: m.AgentDetailView }))); const AgentDetailView = lazy(() => import("./AgentDetailView").then((m) => ({ default: m.AgentDetailView })));
import { ActiveAgentsPanel } from "./ActiveAgentsPanel";
import { AgentMetricsBar } from "./AgentMetricsBar";
import { AgentTokenStatsPanel } from "./AgentTokenStatsPanel"; import { AgentTokenStatsPanel } from "./AgentTokenStatsPanel";
import { AgentsOverviewBar } from "./AgentsOverviewBar";
import { AgentEmptyState } from "./AgentEmptyState"; import { AgentEmptyState } from "./AgentEmptyState";
import { useAgents } from "../hooks/useAgents"; import { useAgents } from "../hooks/useAgents";
import { useConfirm } from "../hooks/useConfirm"; import { useConfirm } from "../hooks/useConfirm";
@@ -214,6 +213,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
const [orgTree, setOrgTree] = useState<OrgTreeNode[]>([]); const [orgTree, setOrgTree] = useState<OrgTreeNode[]>([]);
const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false); const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false);
const [isControlsPanelOpen, setIsControlsPanelOpen] = useState(false); const [isControlsPanelOpen, setIsControlsPanelOpen] = useState(false);
const [isOverviewOpen, setIsOverviewOpen] = useState(false);
const controlsPanelRef = useRef<HTMLDivElement>(null); const controlsPanelRef = useRef<HTMLDivElement>(null);
const { confirm } = useConfirm(); const { confirm } = useConfirm();
const controlsTriggerRef = useRef<HTMLButtonElement>(null); const controlsTriggerRef = useRef<HTMLButtonElement>(null);
@@ -849,13 +849,19 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
</div> </div>
)} )}
<AgentsOverviewBar
stats={stats}
activeAgents={displayActiveAgents}
projectId={projectId}
isOpen={isOverviewOpen}
onToggle={() => setIsOverviewOpen((open) => !open)}
onSelectAgent={setSelectedAgentId}
onOpenTaskLogs={onOpenTaskLogs}
/>
<div className="agents-split-layout"> <div className="agents-split-layout">
<div className={`agents-split-sidebar${isMobileDetailOpen ? " agents-split-sidebar--hidden-mobile" : ""}`}> <div className={`agents-split-sidebar${isMobileDetailOpen ? " agents-split-sidebar--hidden-mobile" : ""}`}>
<div className="agents-view-content"> <div className="agents-view-content">
<AgentMetricsBar stats={stats} />
<ActiveAgentsPanel agents={displayActiveAgents} projectId={projectId} onAgentSelect={setSelectedAgentId} onOpenTaskLogs={onOpenTaskLogs} />
<NewAgentDialog <NewAgentDialog
isOpen={isCreating} isOpen={isCreating}
onClose={() => { onClose={() => {

View File

@@ -147,6 +147,17 @@ describe("AgentsView", () => {
return trigger; return trigger;
}; };
const openOverviewPanel = async () => {
const toggle = await screen.findByRole("button", { name: /Overview/i });
if (toggle.getAttribute("aria-expanded") !== "true") {
fireEvent.click(toggle);
}
await waitFor(() => {
expect(toggle.getAttribute("aria-expanded")).toBe("true");
});
return toggle;
};
describe("rendering", () => { describe("rendering", () => {
it("renders the agents view header", async () => { it("renders the agents view header", async () => {
render(<AgentsView addToast={mockAddToast} />); render(<AgentsView addToast={mockAddToast} />);
@@ -186,14 +197,20 @@ describe("AgentsView", () => {
expect(screen.getByText("review")).toHaveAttribute("title", "auto::skills/../../.agents/skills/review/SKILL.md"); expect(screen.getByText("review")).toHaveAttribute("title", "auto::skills/../../.agents/skills/review/SKILL.md");
}); });
it("renders split layout with sidebar and detail pane", async () => { it("renders cross-pane overview above split layout", async () => {
const { container } = render(<AgentsView addToast={mockAddToast} />); const { container } = render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => { await waitFor(() => {
expect(container.querySelector(".agents-overview-bar")).toBeTruthy();
expect(container.querySelector(".agents-split-layout")).toBeTruthy(); expect(container.querySelector(".agents-split-layout")).toBeTruthy();
}); });
expect(container.querySelector(".agents-split-sidebar")).toBeTruthy(); const overview = container.querySelector(".agents-overview-bar");
const splitLayout = container.querySelector(".agents-split-layout");
expect(overview?.nextElementSibling).toBe(splitLayout);
const sidebar = container.querySelector(".agents-split-sidebar");
expect(sidebar).toBeTruthy();
expect(sidebar?.querySelector(".agents-overview-bar")).toBeNull();
expect(container.querySelector(".agents-split-detail")).toBeTruthy(); expect(container.querySelector(".agents-split-detail")).toBeTruthy();
expect(screen.getByText("Select an agent")).toBeInTheDocument(); expect(screen.getByText("Select an agent")).toBeInTheDocument();
expect(screen.getByText("Choose an agent from the sidebar to view details")).toBeInTheDocument(); expect(screen.getByText("Choose an agent from the sidebar to view details")).toBeInTheDocument();
@@ -422,24 +439,25 @@ describe("AgentsView", () => {
}); });
}); });
it("renders metrics, then active panel, then the main collection", async () => { it("keeps metrics and active agents collapsed behind overview disclosure by default", async () => {
const { container } = render(<AgentsView addToast={mockAddToast} />); const { container } = render(<AgentsView addToast={mockAddToast} />);
await waitFor(() => { await waitFor(() => {
expect(container.querySelector(".agent-list")).toBeTruthy(); expect(container.querySelector(".agent-list")).toBeTruthy();
});
const overviewToggle = screen.getByRole("button", { name: /Overview/i });
expect(overviewToggle.getAttribute("aria-expanded")).toBe("false");
expect(container.querySelector(".agent-metrics-bar")).toBeNull();
expect(container.querySelector(".active-agents-panel")).toBeNull();
fireEvent.click(overviewToggle);
await waitFor(() => {
expect(overviewToggle.getAttribute("aria-expanded")).toBe("true");
expect(container.querySelector(".agent-metrics-bar")).toBeTruthy(); expect(container.querySelector(".agent-metrics-bar")).toBeTruthy();
expect(container.querySelector(".active-agents-panel")).toBeTruthy(); expect(container.querySelector(".active-agents-panel")).toBeTruthy();
}); });
const list = container.querySelector(".agent-list");
const metrics = container.querySelector(".agent-metrics-bar");
const activePanel = container.querySelector(".active-agents-panel");
expect(list && metrics && activePanel).toBeTruthy();
// Metrics bar sits at the very top of the view content.
expect(metrics!.compareDocumentPosition(activePanel!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
// Active agents panel sits between metrics and the main agent list,
// so live work is visible without scrolling past the full directory.
expect(activePanel!.compareDocumentPosition(list!) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
}); });
it("fetches agents only once on mount (regression: no duplicate initial load path)", async () => { it("fetches agents only once on mount (regression: no duplicate initial load path)", async () => {
@@ -451,6 +469,7 @@ describe("AgentsView", () => {
}); });
// Ensure the single-load path still powers dependent UI sections. // Ensure the single-load path still powers dependent UI sections.
await openOverviewPanel();
expect(await screen.findByText("Active Agents (1)")).toBeTruthy(); expect(await screen.findByText("Active Agents (1)")).toBeTruthy();
}); });
@@ -1833,6 +1852,7 @@ describe("AgentsView", () => {
it("renders active agents panel when agents are active", async () => { it("renders active agents panel when agents are active", async () => {
// agent-002 is active with taskId FN-001 // agent-002 is active with taskId FN-001
render(<AgentsView addToast={mockAddToast} />); render(<AgentsView addToast={mockAddToast} />);
await openOverviewPanel();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Active Agents (1)")).toBeTruthy(); expect(screen.getByText("Active Agents (1)")).toBeTruthy();
@@ -1845,6 +1865,7 @@ describe("AgentsView", () => {
it("opens AgentDetailView when clicking an active agent card", async () => { it("opens AgentDetailView when clicking an active agent card", async () => {
render(<AgentsView addToast={mockAddToast} />); render(<AgentsView addToast={mockAddToast} />);
await openOverviewPanel();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Active Agents (1)")).toBeTruthy(); expect(screen.getByText("Active Agents (1)")).toBeTruthy();
@@ -1864,6 +1885,7 @@ describe("AgentsView", () => {
it("opens AgentDetailView when pressing Enter on an active agent card", async () => { it("opens AgentDetailView when pressing Enter on an active agent card", async () => {
render(<AgentsView addToast={mockAddToast} />); render(<AgentsView addToast={mockAddToast} />);
await openOverviewPanel();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Active Agents (1)")).toBeTruthy(); expect(screen.getByText("Active Agents (1)")).toBeTruthy();
@@ -1884,6 +1906,7 @@ describe("AgentsView", () => {
it("opens AgentDetailView when pressing Space on an active agent card", async () => { it("opens AgentDetailView when pressing Space on an active agent card", async () => {
render(<AgentsView addToast={mockAddToast} />); render(<AgentsView addToast={mockAddToast} />);
await openOverviewPanel();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Active Agents (1)")).toBeTruthy(); expect(screen.getByText("Active Agents (1)")).toBeTruthy();
@@ -1904,6 +1927,7 @@ describe("AgentsView", () => {
it("live agent cards have proper accessibility attributes", async () => { it("live agent cards have proper accessibility attributes", async () => {
render(<AgentsView addToast={mockAddToast} />); render(<AgentsView addToast={mockAddToast} />);
await openOverviewPanel();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Active Agents (1)")).toBeTruthy(); expect(screen.getByText("Active Agents (1)")).toBeTruthy();
@@ -1934,9 +1958,10 @@ describe("AgentsView", () => {
mockFetchAgents.mockResolvedValue(inactiveAgents); mockFetchAgents.mockResolvedValue(inactiveAgents);
render(<AgentsView addToast={mockAddToast} />); render(<AgentsView addToast={mockAddToast} />);
await openOverviewPanel();
await waitFor(() => { await waitFor(() => {
expect(screen.queryByText("Active Agents")).toBeNull(); expect(screen.queryByText(/^Active Agents \(/)).toBeNull();
}); });
}); });
@@ -1959,6 +1984,7 @@ describe("AgentsView", () => {
mockFetchAgents.mockResolvedValue(spawnedAgents); mockFetchAgents.mockResolvedValue(spawnedAgents);
render(<AgentsView addToast={mockAddToast} />); render(<AgentsView addToast={mockAddToast} />);
await openOverviewPanel();
await waitFor(() => { await waitFor(() => {
expect(screen.getByText("Active Agents (2)")).toBeTruthy(); expect(screen.getByText("Active Agents (2)")).toBeTruthy();