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:
gsxdsm
2026-06-20 02:25:11 -07:00
parent 7eceec00cc
commit c4f34ceed1
6 changed files with 280 additions and 9 deletions

View 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.

View File

@@ -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);
}
}

View File

@@ -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}>

View File

@@ -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} />);

View File

@@ -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", () => {

View File

@@ -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",