FN-6767: make agents sidebar resizable
Make the Agents split view wider by default and persist user-adjusted sidebar widths. - Add a desktop/tablet resize handle with pointer and keyboard controls, clamped accessible values, and per-project storage.\n- Update split-layout CSS so mobile remains stacked while non-mobile views reserve a resize affordance column.\n- Cover default, stored, clamped, pointer, keyboard, mobile, and org-chart sidebar behaviors in tests.\n- Add a patch changeset for the published Fusion package.\n\nFiles changed:\n .changeset/fn-6767-agents-sidebar.md | 5 +\n packages/dashboard/app/components/AgentsView.css | 47 ++++++--\n packages/dashboard/app/components/AgentsView.tsx | 101 +++++++++++++++-\n .../app/components/__tests__/AgentsView.test.tsx | 132 +++++++++++++++++++++\n .../app/utils/__tests__/projectStorage.test.ts | 3 +-\n packages/dashboard/app/utils/projectStorage.ts | 1 +\n 6 files changed, 280 insertions(+), 9 deletions(-) Fusion-Task-Id: FN-6767 Fusion-Task-Lineage: 93605e16-7524-4577-bbcd-666c105a867b
This commit is contained in:
5
.changeset/fn-6767-agents-sidebar.md
Normal file
5
.changeset/fn-6767-agents-sidebar.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
Make the Agents view sidebar wider by default on tablet and resizable with per-project persistence on non-mobile layouts.
|
||||
@@ -221,7 +221,7 @@
|
||||
|
||||
.agents-split-layout {
|
||||
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))) var(--space-sm) minmax(0, 1fr);
|
||||
gap: 0;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
@@ -235,6 +235,41 @@
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.agents-split-resize-handle {
|
||||
position: relative;
|
||||
width: var(--space-sm);
|
||||
min-width: var(--space-sm);
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
touch-action: none;
|
||||
transition: background var(--transition-fast);
|
||||
}
|
||||
|
||||
/*
|
||||
FNXC:AgentsView 2026-06-20-00:00:
|
||||
The resize affordance must match MissionManager's accessible split-pane handle while preserving a token-only, no-mobile-shell layout.
|
||||
The base grid keeps a token-sized handle column as the no-JS fallback, while the React inline grid width owns desktop/tablet persistence.
|
||||
*/
|
||||
.agents-split-resize-handle::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
width: var(--space-xs);
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.agents-split-resize-handle:hover::before,
|
||||
.agents-split-resize-handle:active::before {
|
||||
background: color-mix(in srgb, var(--todo) 30%, transparent);
|
||||
}
|
||||
|
||||
.agents-split-resize-handle:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
}
|
||||
|
||||
.agents-split-detail {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
@@ -1490,6 +1525,10 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agents-split-resize-handle {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.agents-split-detail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -1511,9 +1550,3 @@
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.agents-split-layout {
|
||||
grid-template-columns: minmax(calc(var(--space-xl) * 10), calc(var(--space-xl) * 11 + var(--space-md))) minmax(0, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,28 @@ const ORG_CHART_SCALE_MAX = 3;
|
||||
const ORG_CHART_KEYBOARD_PAN_STEP = 16;
|
||||
const ORG_CHART_OVERSCROLL = 32;
|
||||
|
||||
/*
|
||||
FNXC:AgentsView 2026-06-20-00:00:
|
||||
The Agents split view needs a wider tablet default than the old fixed CSS column and the sidebar must be user-resizable on non-mobile viewports.
|
||||
Persist the clamped width per project so desktop and tablet users keep their preferred agent-list/detail balance without affecting the stacked mobile layout.
|
||||
*/
|
||||
const AGENTS_SIDEBAR_DEFAULT_WIDTH = 320;
|
||||
const AGENTS_SIDEBAR_MIN_WIDTH = 260;
|
||||
const AGENTS_SIDEBAR_MAX_WIDTH = 520;
|
||||
const AGENTS_SIDEBAR_WIDTH_STORAGE_KEY = "kb-dashboard-agents-sidebar-width";
|
||||
|
||||
function clampAgentsSidebarWidth(width: number): number {
|
||||
return Math.max(AGENTS_SIDEBAR_MIN_WIDTH, Math.min(AGENTS_SIDEBAR_MAX_WIDTH, width));
|
||||
}
|
||||
|
||||
function readAgentsSidebarWidth(projectId?: string): number {
|
||||
if (typeof window === "undefined") return AGENTS_SIDEBAR_DEFAULT_WIDTH;
|
||||
const stored = getScopedItem(AGENTS_SIDEBAR_WIDTH_STORAGE_KEY, projectId);
|
||||
const parsed = stored ? Number(stored) : NaN;
|
||||
if (!Number.isFinite(parsed)) return AGENTS_SIDEBAR_DEFAULT_WIDTH;
|
||||
return clampAgentsSidebarWidth(parsed);
|
||||
}
|
||||
|
||||
function getStateBadgeClass(state: AgentState): string {
|
||||
switch (state) {
|
||||
case "running":
|
||||
@@ -272,6 +294,7 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
const [showSystemAgents, setShowSystemAgents] = useState(false);
|
||||
const viewportMode = useViewportMode();
|
||||
const isMobileViewport = viewportMode === "mobile";
|
||||
const [sidebarWidth, setSidebarWidth] = useState<number>(() => readAgentsSidebarWidth(projectId));
|
||||
const [filterState, setFilterState] = useState<AgentState | "all">("all");
|
||||
const { agents, stats, isLoading, loadAgents, refreshAgents } = useAgents(projectId, {
|
||||
filterState,
|
||||
@@ -320,6 +343,10 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
const controlsTriggerRef = useRef<HTMLButtonElement>(null);
|
||||
const controlsPanelId = useId();
|
||||
|
||||
useEffect(() => {
|
||||
setSidebarWidth(readAgentsSidebarWidth(projectId));
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
const saved = getScopedItem("fn-agent-view", projectId);
|
||||
if (saved === "list" || saved === "board" || saved === "org") {
|
||||
@@ -343,6 +370,59 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
setScopedItem(ORG_CHART_LAYOUT_STORAGE_KEY, orgChartLayoutPreference, projectId);
|
||||
}, [orgChartLayoutPreference, projectId]);
|
||||
|
||||
const persistSidebarWidth = useCallback((width: number) => {
|
||||
try {
|
||||
setScopedItem(AGENTS_SIDEBAR_WIDTH_STORAGE_KEY, String(width), projectId);
|
||||
} catch {
|
||||
// Ignore storage errors.
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const handleSidebarResizeStart = useCallback((event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (isMobileViewport) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const handle = event.currentTarget;
|
||||
if (typeof handle.setPointerCapture === "function") {
|
||||
handle.setPointerCapture(event.pointerId);
|
||||
}
|
||||
const startX = event.clientX;
|
||||
const startWidth = sidebarWidth;
|
||||
let latestWidth = startWidth;
|
||||
document.body.style.userSelect = "none";
|
||||
|
||||
const onPointerMove = (moveEvent: PointerEvent) => {
|
||||
const deltaX = moveEvent.clientX - startX;
|
||||
const nextWidth = clampAgentsSidebarWidth(startWidth + deltaX);
|
||||
latestWidth = nextWidth;
|
||||
setSidebarWidth(nextWidth);
|
||||
};
|
||||
|
||||
const onPointerUp = (upEvent: PointerEvent) => {
|
||||
if (typeof handle.releasePointerCapture === "function") {
|
||||
handle.releasePointerCapture(upEvent.pointerId);
|
||||
}
|
||||
document.body.style.userSelect = "";
|
||||
document.removeEventListener("pointermove", onPointerMove);
|
||||
document.removeEventListener("pointerup", onPointerUp);
|
||||
persistSidebarWidth(latestWidth);
|
||||
};
|
||||
|
||||
document.addEventListener("pointermove", onPointerMove);
|
||||
document.addEventListener("pointerup", onPointerUp);
|
||||
}, [isMobileViewport, persistSidebarWidth, sidebarWidth]);
|
||||
|
||||
const handleSidebarResizeKeyDown = useCallback((event: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
if (isMobileViewport) return;
|
||||
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
|
||||
event.preventDefault();
|
||||
const step = event.shiftKey ? 50 : 10;
|
||||
const delta = event.key === "ArrowLeft" ? -step : step;
|
||||
const nextWidth = clampAgentsSidebarWidth(sidebarWidth + delta);
|
||||
setSidebarWidth(nextWidth);
|
||||
persistSidebarWidth(nextWidth);
|
||||
}, [isMobileViewport, persistSidebarWidth, sidebarWidth]);
|
||||
|
||||
const [editingRoleForAgent, setEditingRoleForAgent] = useState<string | null>(null);
|
||||
const roleSelectRef = useRef<HTMLSelectElement>(null);
|
||||
const [updatingHeartbeatAgentId, setUpdatingHeartbeatAgentId] = useState<string | null>(null);
|
||||
@@ -1547,7 +1627,10 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="agents-split-layout">
|
||||
<div
|
||||
className="agents-split-layout"
|
||||
style={isMobileViewport ? undefined : { gridTemplateColumns: `${sidebarWidth}px var(--space-sm) minmax(0, 1fr)` }}
|
||||
>
|
||||
<div className={`agents-split-sidebar${isMobileDetailOpen ? " agents-split-sidebar--hidden-mobile" : ""}`}>
|
||||
<div className="agents-view-content">
|
||||
{/* Agent Collection */}
|
||||
@@ -1969,6 +2052,22 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
|
||||
|
||||
</div>
|
||||
|
||||
{!isMobileViewport && (
|
||||
<div
|
||||
className="agents-split-resize-handle"
|
||||
data-testid="agents-sidebar-resize-handle"
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-valuemin={AGENTS_SIDEBAR_MIN_WIDTH}
|
||||
aria-valuemax={AGENTS_SIDEBAR_MAX_WIDTH}
|
||||
aria-valuenow={sidebarWidth}
|
||||
aria-label={t("agents.resizeSidebar", "Resize agents sidebar")}
|
||||
tabIndex={0}
|
||||
onPointerDown={handleSidebarResizeStart}
|
||||
onKeyDown={handleSidebarResizeKeyDown}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={`agents-split-detail${isMobileViewport && !selectedAgentId ? " agents-split-detail--hidden-mobile" : ""}`}>
|
||||
{selectedAgentId ? (
|
||||
<Suspense fallback={null}>
|
||||
|
||||
@@ -105,6 +105,7 @@ const mockResizeObserverDisconnect = vi.fn();
|
||||
describe("AgentsView", () => {
|
||||
const mockAddToast = vi.fn();
|
||||
const projectId = "proj_123";
|
||||
const agentsSidebarWidthKey = "kb-dashboard-agents-sidebar-width";
|
||||
|
||||
const mockAgents: Agent[] = [
|
||||
{
|
||||
@@ -310,6 +311,121 @@ describe("AgentsView", () => {
|
||||
expect(container.querySelector(".agents-sidebar-quick-controls")).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["desktop", "tablet"] as const)("renders an accessible resize handle on %s split layouts", async (mode) => {
|
||||
mockViewportMode.mockReturnValue(mode);
|
||||
const { container } = render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||
|
||||
const handle = await screen.findByTestId("agents-sidebar-resize-handle");
|
||||
expect(handle).toHaveAttribute("role", "separator");
|
||||
expect(handle).toHaveAttribute("aria-orientation", "vertical");
|
||||
expect(handle).toHaveAttribute("aria-valuemin", "260");
|
||||
expect(handle).toHaveAttribute("aria-valuemax", "520");
|
||||
expect(handle).toHaveAttribute("aria-valuenow", "320");
|
||||
expect(container.querySelector<HTMLElement>(".agents-split-layout")?.style.gridTemplateColumns).toBe("320px var(--space-sm) minmax(0, 1fr)");
|
||||
});
|
||||
|
||||
it("does not render the resize handle or inline split width on mobile", async () => {
|
||||
mockViewportMode.mockReturnValue("mobile");
|
||||
const { container } = render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Agents")).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("agents-sidebar-resize-handle")).toBeNull();
|
||||
expect(container.querySelector<HTMLElement>(".agents-split-layout")?.style.gridTemplateColumns).toBe("");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "no stored value", stored: null, expected: 320 },
|
||||
{ label: "valid stored value", stored: "410", expected: 410 },
|
||||
{ label: "corrupt stored value", stored: "not-a-number", expected: 320 },
|
||||
{ label: "above max stored value", stored: "999", expected: 520 },
|
||||
{ label: "below min stored value", stored: "10", expected: 260 },
|
||||
])("initializes sidebar width from $label", async ({ stored, expected }) => {
|
||||
if (stored !== null) {
|
||||
localStorage.setItem(scopedKey(agentsSidebarWidthKey, projectId), stored);
|
||||
}
|
||||
|
||||
const { container } = render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||
|
||||
const handle = await screen.findByTestId("agents-sidebar-resize-handle");
|
||||
expect(handle).toHaveAttribute("aria-valuenow", String(expected));
|
||||
expect(container.querySelector<HTMLElement>(".agents-split-layout")?.style.gridTemplateColumns).toBe(`${expected}px var(--space-sm) minmax(0, 1fr)`);
|
||||
});
|
||||
|
||||
it("supports keyboard resizing with project-scoped persistence and clamping", async () => {
|
||||
localStorage.setItem(scopedKey(agentsSidebarWidthKey, projectId), "515");
|
||||
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||
|
||||
const handle = await screen.findByTestId("agents-sidebar-resize-handle");
|
||||
|
||||
fireEvent.keyDown(handle, { key: "ArrowRight", shiftKey: true });
|
||||
await waitFor(() => {
|
||||
expect(handle).toHaveAttribute("aria-valuenow", "520");
|
||||
expect(localStorage.getItem(scopedKey(agentsSidebarWidthKey, projectId))).toBe("520");
|
||||
});
|
||||
|
||||
fireEvent.keyDown(handle, { key: "ArrowLeft", shiftKey: true });
|
||||
expect(handle).toHaveAttribute("aria-valuenow", "470");
|
||||
expect(localStorage.getItem(scopedKey(agentsSidebarWidthKey, projectId))).toBe("470");
|
||||
|
||||
fireEvent.keyDown(handle, { key: "ArrowLeft" });
|
||||
expect(handle).toHaveAttribute("aria-valuenow", "460");
|
||||
expect(localStorage.getItem(scopedKey(agentsSidebarWidthKey, projectId))).toBe("460");
|
||||
});
|
||||
|
||||
it("clamps keyboard resizing at the minimum width", async () => {
|
||||
localStorage.setItem(scopedKey(agentsSidebarWidthKey, projectId), "260");
|
||||
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||
|
||||
const handle = await screen.findByTestId("agents-sidebar-resize-handle");
|
||||
fireEvent.keyDown(handle, { key: "ArrowLeft", shiftKey: true });
|
||||
|
||||
expect(handle).toHaveAttribute("aria-valuenow", "260");
|
||||
expect(localStorage.getItem(scopedKey(agentsSidebarWidthKey, projectId))).toBe("260");
|
||||
});
|
||||
|
||||
it("supports pointer drag resizing with capture, cleanup, persistence, and max clamping", async () => {
|
||||
localStorage.setItem(scopedKey(agentsSidebarWidthKey, projectId), "500");
|
||||
const { container } = render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||
const handle = await screen.findByTestId("agents-sidebar-resize-handle");
|
||||
const setPointerCapture = vi.fn();
|
||||
const releasePointerCapture = vi.fn();
|
||||
Object.defineProperty(handle, "setPointerCapture", { configurable: true, value: setPointerCapture });
|
||||
Object.defineProperty(handle, "releasePointerCapture", { configurable: true, value: releasePointerCapture });
|
||||
|
||||
fireEvent.pointerDown(handle, { pointerId: 1, clientX: 300 });
|
||||
expect(setPointerCapture).toHaveBeenCalledWith(1);
|
||||
expect(document.body.style.userSelect).toBe("none");
|
||||
|
||||
fireEvent.pointerMove(document, { pointerId: 1, clientX: 400 });
|
||||
await waitFor(() => {
|
||||
expect(handle).toHaveAttribute("aria-valuenow", "520");
|
||||
});
|
||||
expect(container.querySelector<HTMLElement>(".agents-split-layout")?.style.gridTemplateColumns).toBe("520px var(--space-sm) minmax(0, 1fr)");
|
||||
|
||||
fireEvent.pointerUp(document, { pointerId: 1 });
|
||||
expect(releasePointerCapture).toHaveBeenCalledWith(1);
|
||||
expect(document.body.style.userSelect).toBe("");
|
||||
expect(localStorage.getItem(scopedKey(agentsSidebarWidthKey, projectId))).toBe("520");
|
||||
});
|
||||
|
||||
it("supports pointer drag resizing with min clamping", async () => {
|
||||
localStorage.setItem(scopedKey(agentsSidebarWidthKey, projectId), "300");
|
||||
render(<AgentsView addToast={mockAddToast} projectId={projectId} />);
|
||||
const handle = await screen.findByTestId("agents-sidebar-resize-handle");
|
||||
|
||||
fireEvent.pointerDown(handle, { pointerId: 2, clientX: 300 });
|
||||
fireEvent.pointerMove(document, { pointerId: 2, clientX: 0 });
|
||||
await waitFor(() => {
|
||||
expect(handle).toHaveAttribute("aria-valuenow", "260");
|
||||
});
|
||||
fireEvent.pointerUp(document, { pointerId: 2 });
|
||||
|
||||
expect(localStorage.getItem(scopedKey(agentsSidebarWidthKey, projectId))).toBe("260");
|
||||
});
|
||||
|
||||
it("supports mobile drill-in detail with back navigation", async () => {
|
||||
mockViewportMode.mockReturnValue("mobile");
|
||||
const { container } = render(<AgentsView addToast={mockAddToast} />);
|
||||
@@ -1345,6 +1461,22 @@ describe("AgentsView", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not render the split resize handle in org chart view", async () => {
|
||||
mockFetchOrgTree.mockResolvedValue(orgTree);
|
||||
const { container } = render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
expect(await screen.findByTestId("agents-sidebar-resize-handle")).toBeTruthy();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Org Chart view" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".agents-org-full-view")).toBeTruthy();
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId("agents-sidebar-resize-handle")).toBeNull();
|
||||
expect(container.querySelector(".agents-split-layout")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders org chart nodes and opens detail view when clicking a node", async () => {
|
||||
mockFetchOrgTree.mockResolvedValue(orgTree);
|
||||
const { container } = render(<AgentsView addToast={mockAddToast} />);
|
||||
|
||||
@@ -84,6 +84,7 @@ describe("projectStorage", () => {
|
||||
"kb-dashboard-list-selected-task",
|
||||
"kb-dashboard-list-sidebar-width",
|
||||
"kb-dashboard-mailbox-sidebar-width",
|
||||
"kb-dashboard-agents-sidebar-width",
|
||||
"kb-quick-entry-text",
|
||||
"kb-inline-create-text",
|
||||
"fn-agent-view",
|
||||
@@ -103,7 +104,7 @@ describe("projectStorage", () => {
|
||||
"fusion-plugin-dependency-graph:positions",
|
||||
]),
|
||||
);
|
||||
expect(PROJECT_STORAGE_KEYS).toHaveLength(25);
|
||||
expect(PROJECT_STORAGE_KEYS).toHaveLength(26);
|
||||
});
|
||||
|
||||
it("stores branch filter values as scoped strings per project", () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ export const PROJECT_STORAGE_KEYS: string[] = [
|
||||
"kb-dashboard-list-selected-task",
|
||||
"kb-dashboard-list-sidebar-width",
|
||||
"kb-dashboard-mailbox-sidebar-width",
|
||||
"kb-dashboard-agents-sidebar-width",
|
||||
"kb-quick-entry-text",
|
||||
"kb-inline-create-text",
|
||||
"fn-agent-view",
|
||||
|
||||
Reference in New Issue
Block a user