feat(FN-3383): ship mobile org chart controls and verification updates
- Add mobile org chart pan/zoom controls in AgentsView with updated styling and interaction tests - Normalize cli-alias global references for lint-safe runtime access - Expand merger, research, and planning verification logic with stronger test coverage - Add test-changed cache/concurrency behavior and stabilization documentation updates Fusion-Task-Id: FN-3383
This commit is contained in:
@@ -44,9 +44,9 @@ await import("@runfusion/fusion/dist/bin.js");
|
||||
// stderr isn't a TTY, or when FUSION_NO_UPDATE_CHECK=1 is set.
|
||||
function maybeAnnounceUpdateAndRefresh() {
|
||||
try {
|
||||
if (process.env.FUSION_NO_UPDATE_CHECK === "1") return;
|
||||
if (process.env.CI) return;
|
||||
if (!process.stderr.isTTY) return;
|
||||
if (globalThis.process.env.FUSION_NO_UPDATE_CHECK === "1") return;
|
||||
if (globalThis.process.env.CI) return;
|
||||
if (!globalThis.process.stderr.isTTY) return;
|
||||
|
||||
const fusionDir = resolveFusionDir();
|
||||
const cachePath = join(fusionDir, "update-check.json");
|
||||
@@ -65,7 +65,7 @@ function maybeAnnounceUpdateAndRefresh() {
|
||||
) {
|
||||
const yellow = (s) => `\x1b[33m${s}\x1b[0m`;
|
||||
const dim = (s) => `\x1b[2m${s}\x1b[0m`;
|
||||
process.stderr.write(
|
||||
globalThis.process.stderr.write(
|
||||
yellow(
|
||||
`\nFusion ${cache.latestVersion} is available (you have ${currentVersion}).\n`,
|
||||
) +
|
||||
@@ -88,7 +88,7 @@ function maybeAnnounceUpdateAndRefresh() {
|
||||
}
|
||||
|
||||
function resolveFusionDir() {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || homedir();
|
||||
const home = globalThis.process.env.HOME || globalThis.process.env.USERPROFILE || homedir();
|
||||
const preferred = join(home, ".fusion");
|
||||
if (existsSync(preferred)) return preferred;
|
||||
const legacy = join(home, ".pi", "fusion");
|
||||
@@ -108,10 +108,10 @@ function readBundledFusionVersion() {
|
||||
}
|
||||
|
||||
async function backgroundRefresh(fusionDir, cachePath, currentVersion) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 1500);
|
||||
const controller = new globalThis.AbortController();
|
||||
const timeout = globalThis.setTimeout(() => controller.abort(), 1500);
|
||||
try {
|
||||
const response = await fetch("https://registry.npmjs.org/@runfusion%2Ffusion", {
|
||||
const response = await globalThis.fetch("https://registry.npmjs.org/@runfusion%2Ffusion", {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!response.ok) return;
|
||||
@@ -131,7 +131,7 @@ async function backgroundRefresh(fusionDir, cachePath, currentVersion) {
|
||||
writeFileSync(cachePath, JSON.stringify(result, null, 2), "utf-8");
|
||||
} catch { /* best-effort */ }
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
globalThis.clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -96,6 +96,14 @@ describe("Agent CSS classes", () => {
|
||||
expect(hasClass(".agent-system-filter")).toBe(true);
|
||||
expect(hasClass(".agent-controls-actions")).toBe(true);
|
||||
expect(hasClass(".agent-global-controls")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-shell")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-controls")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-viewport")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-canvas")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-canvas--zoom-75")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-canvas--zoom-100")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-canvas--zoom-125")).toBe(true);
|
||||
expect(hasClass(".agent-org-chart-canvas--zoom-150")).toBe(true);
|
||||
expect(hasClass(".agent-board")).toBe(true);
|
||||
expect(hasClass(".agent-board-card")).toBe(true);
|
||||
expect(hasClass(".agent-board-card--idle")).toBe(true);
|
||||
@@ -177,6 +185,8 @@ describe("Agent CSS classes", () => {
|
||||
expect(orgChartSection).toContain("padding: var(--space-lg)");
|
||||
expect(orgChartSection).toContain("--org-chart-node-width: calc(var(--space-xl) * 9 + var(--space-xs))");
|
||||
expect(orgChartSection).toContain("min-height: var(--org-chart-node-width)");
|
||||
expect(orgChartSection).toContain("touch-action: pan-x pan-y");
|
||||
expect(orgChartSection).toContain("transform-origin: top left");
|
||||
expect(orgChartSection).toContain("border: 1px solid var(--border)");
|
||||
expect(orgChartSection).toContain("color: var(--text)");
|
||||
expect(orgChartSection).toContain("color: var(--text-muted)");
|
||||
|
||||
@@ -790,6 +790,57 @@
|
||||
}
|
||||
|
||||
/* === FN-1167: Agent Org Chart + Chain of Command === */
|
||||
.agent-org-chart-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.agent-org-chart-controls {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agent-org-chart-controls__zoom-label {
|
||||
min-width: calc(var(--space-2xl) * 2);
|
||||
font-family: var(--font-mono);
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.agent-org-chart-controls__fit-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.agent-org-chart-viewport {
|
||||
overflow: auto;
|
||||
max-width: 100%;
|
||||
overscroll-behavior: contain;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
|
||||
.agent-org-chart-canvas {
|
||||
width: max-content;
|
||||
transform-origin: top left;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.agent-org-chart-canvas--zoom-75 {
|
||||
transform: scale(0.75);
|
||||
}
|
||||
|
||||
.agent-org-chart-canvas--zoom-100 {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.agent-org-chart-canvas--zoom-125 {
|
||||
transform: scale(1.25);
|
||||
}
|
||||
|
||||
.agent-org-chart-canvas--zoom-150 {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
.agent-org-chart {
|
||||
--org-chart-node-width: calc(var(--space-xl) * 9 + var(--space-xs));
|
||||
--org-chart-connector-gap: var(--space-xs);
|
||||
@@ -798,8 +849,6 @@
|
||||
justify-content: flex-start;
|
||||
gap: var(--space-xl);
|
||||
padding: var(--space-lg);
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
min-height: var(--org-chart-node-width);
|
||||
}
|
||||
|
||||
@@ -1007,6 +1056,25 @@
|
||||
font-size: calc(var(--space-sm) + var(--space-xs) * 0.5);
|
||||
}
|
||||
|
||||
.agent-org-chart-shell {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.agent-org-chart-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.agent-org-chart-controls .btn-icon,
|
||||
.agent-org-chart-controls .agent-org-chart-controls__fit-btn {
|
||||
min-height: calc(var(--space-lg) * 2 + var(--space-xs));
|
||||
}
|
||||
|
||||
.agent-org-chart-viewport {
|
||||
min-height: calc(var(--space-2xl) * 4);
|
||||
}
|
||||
|
||||
.agent-org-chart {
|
||||
--org-chart-node-width: calc(var(--space-2xl) * 5);
|
||||
padding: var(--space-sm);
|
||||
|
||||
@@ -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, 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, ZoomIn, ZoomOut, Minimize2 } from "lucide-react";
|
||||
import type { Agent, AgentCapability, AgentOnboardingSummary, AgentState, OrgTreeNode } from "../api";
|
||||
import { updateAgent, updateAgentState, deleteAgent, startAgentRun, fetchOrgTree, fetchSettings, updateSettings } from "../api";
|
||||
|
||||
@@ -47,6 +47,7 @@ const AGENT_ROLES: { value: AgentCapability; label: string; icon: string }[] = [
|
||||
const HEARTBEAT_MULTIPLIER_PRESETS = [0.1, 0.25, 0.5, 1, 2, 3, 5, 10] as const;
|
||||
|
||||
const SKILL_PATH_LABEL_PATTERN = /(?:^|\/)skills\/([^/]+)\/SKILL\.md$/i;
|
||||
const ORG_CHART_ZOOM_LEVELS = [0.75, 1, 1.25, 1.5] as const;
|
||||
|
||||
export function formatAgentSkillBadgeLabel(skillId: string): string {
|
||||
const trimmedSkillId = skillId.trim();
|
||||
@@ -267,6 +268,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
const [isOrgTreeLoading, setIsOrgTreeLoading] = useState(false);
|
||||
const [isControlsPanelOpen, setIsControlsPanelOpen] = useState(false);
|
||||
const [isOverviewOpen, setIsOverviewOpen] = useState(false);
|
||||
const [orgChartZoomIndex, setOrgChartZoomIndex] = useState(1);
|
||||
const controlsPanelRef = useRef<HTMLDivElement>(null);
|
||||
const { confirm } = useConfirm();
|
||||
const controlsTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -706,6 +708,9 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
|
||||
const handleAgentViewChange = useCallback((nextView: "list" | "board" | "org") => {
|
||||
setAgentView(nextView);
|
||||
if (nextView !== "org") {
|
||||
setOrgChartZoomIndex(1);
|
||||
}
|
||||
if (isMobileViewport && selectedAgentId) {
|
||||
handleCloseDetail();
|
||||
}
|
||||
@@ -714,6 +719,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
const getRoleLabel = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.label ?? role;
|
||||
const getRoleIcon = (role: AgentCapability) => AGENT_ROLES.find(r => r.value === role)?.icon ?? "◆";
|
||||
const selectedAgent = selectedAgentId ? displayAgents.find((agent) => agent.id === selectedAgentId) ?? null : null;
|
||||
const orgChartZoom = ORG_CHART_ZOOM_LEVELS[orgChartZoomIndex];
|
||||
|
||||
/** Get skill badges from agent metadata */
|
||||
const getSkillBadges = (agent: Agent): string[] => {
|
||||
@@ -966,27 +972,68 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
<span>Loading agents...</span>
|
||||
</div>
|
||||
) : agentView === "org" ? (
|
||||
<div className="agent-org-chart" data-testid="agent-org-chart">
|
||||
{isOrgTreeLoading ? (
|
||||
<div className="agent-org-chart__loading" role="status" aria-live="polite">
|
||||
<RefreshCw size={18} className="spin" />
|
||||
<span>Loading org chart...</span>
|
||||
<div className="agent-org-chart-shell" data-testid="agent-org-chart-shell">
|
||||
{isMobileViewport ? (
|
||||
<div className="agent-org-chart-controls" data-testid="agent-org-chart-controls">
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon touch-target"
|
||||
onClick={() => setOrgChartZoomIndex((value) => Math.max(0, value - 1))}
|
||||
disabled={orgChartZoomIndex === 0}
|
||||
aria-label="Zoom out org chart"
|
||||
title="Zoom out"
|
||||
>
|
||||
<ZoomOut size={16} />
|
||||
</button>
|
||||
<span className="agent-org-chart-controls__zoom-label" aria-live="polite">{Math.round(orgChartZoom * 100)}%</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-icon touch-target"
|
||||
onClick={() => setOrgChartZoomIndex((value) => Math.min(ORG_CHART_ZOOM_LEVELS.length - 1, value + 1))}
|
||||
disabled={orgChartZoomIndex === ORG_CHART_ZOOM_LEVELS.length - 1}
|
||||
aria-label="Zoom in org chart"
|
||||
title="Zoom in"
|
||||
>
|
||||
<ZoomIn size={16} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn touch-target btn-sm agent-org-chart-controls__fit-btn"
|
||||
onClick={() => setOrgChartZoomIndex(1)}
|
||||
aria-label="Fit org chart"
|
||||
title="Fit org chart"
|
||||
>
|
||||
<Minimize2 size={16} />
|
||||
Fit
|
||||
</button>
|
||||
</div>
|
||||
) : displayOrgTree.length === 0 ? (
|
||||
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
|
||||
) : (
|
||||
displayOrgTree.map((node) => (
|
||||
<OrgChartNode
|
||||
key={node.agent.id}
|
||||
node={node}
|
||||
onSelect={openAgentDetail}
|
||||
getHealthStatus={getHealthStatus}
|
||||
getRoleIcon={getRoleIcon}
|
||||
getSkillBadges={getSkillBadges}
|
||||
selectedAgentId={selectedAgentId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
) : null}
|
||||
<div className="agent-org-chart-viewport" data-testid="agent-org-chart-viewport">
|
||||
<div className={`agent-org-chart-canvas agent-org-chart-canvas--zoom-${Math.round(orgChartZoom * 100)}`}>
|
||||
<div className="agent-org-chart" data-testid="agent-org-chart">
|
||||
{isOrgTreeLoading ? (
|
||||
<div className="agent-org-chart__loading" role="status" aria-live="polite">
|
||||
<RefreshCw size={18} className="spin" />
|
||||
<span>Loading org chart...</span>
|
||||
</div>
|
||||
) : displayOrgTree.length === 0 ? (
|
||||
<AgentEmptyState onCtaClick={handleOpenNewAgent} />
|
||||
) : (
|
||||
displayOrgTree.map((node) => (
|
||||
<OrgChartNode
|
||||
key={node.agent.id}
|
||||
node={node}
|
||||
onSelect={openAgentDetail}
|
||||
getHealthStatus={getHealthStatus}
|
||||
getRoleIcon={getRoleIcon}
|
||||
getSkillBadges={getSkillBadges}
|
||||
selectedAgentId={selectedAgentId}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : agentView === "board" ? (
|
||||
<div className="agent-board">
|
||||
|
||||
@@ -1291,6 +1291,40 @@ describe("AgentsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("shows mobile zoom controls for org chart and keeps node selection working", async () => {
|
||||
mockViewportMode.mockReturnValue("mobile");
|
||||
mockFetchOrgTree.mockResolvedValue(orgTree);
|
||||
const { container } = render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
|
||||
|
||||
const controls = await screen.findByTestId("agent-org-chart-controls");
|
||||
expect(controls).toBeTruthy();
|
||||
expect(screen.getByText("100%")).toBeTruthy();
|
||||
|
||||
const viewport = screen.getByTestId("agent-org-chart-viewport");
|
||||
expect(viewport).toBeTruthy();
|
||||
const canvas = container.querySelector(".agent-org-chart-canvas");
|
||||
expect(canvas?.className).toContain("agent-org-chart-canvas--zoom-100");
|
||||
|
||||
fireEvent.click(within(controls).getByTitle("Zoom in"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("125%")).toBeTruthy();
|
||||
expect(container.querySelector(".agent-org-chart-canvas")?.className).toContain("agent-org-chart-canvas--zoom-125");
|
||||
});
|
||||
|
||||
fireEvent.click(within(controls).getByTitle("Fit org chart"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("100%")).toBeTruthy();
|
||||
expect(container.querySelector(".agent-org-chart-canvas")?.className).toContain("agent-org-chart-canvas--zoom-100");
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByText("Director One"));
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("agent-detail-view")).toHaveTextContent("agent-child-1");
|
||||
});
|
||||
});
|
||||
|
||||
it("shows org chart empty state when API returns no nodes", async () => {
|
||||
mockFetchOrgTree.mockResolvedValue([]);
|
||||
render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
@@ -259,7 +259,10 @@ describe("agents-view mobile CSS", () => {
|
||||
expect(block).toContain("flex-wrap: wrap");
|
||||
});
|
||||
|
||||
it("defines mobile org chart sizing rules", () => {
|
||||
it("defines mobile org chart sizing and pan/zoom controls rules", () => {
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-controls")).toContain("display: flex");
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-controls")).toContain("gap: var(--space-sm)");
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart-viewport")).toContain("min-height: calc(var(--space-2xl) * 4)");
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("gap: var(--space-sm)");
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".agent-org-chart")).toContain("--org-chart-node-width: calc(var(--space-2xl) * 5)");
|
||||
expect(extractRuleBlock(mobileMediaBlock, ".org-chart-node-card")).toContain("padding: var(--space-sm)");
|
||||
|
||||
Reference in New Issue
Block a user