diff --git a/.changeset/fn-6767-agents-sidebar.md b/.changeset/fn-6767-agents-sidebar.md new file mode 100644 index 0000000000..6b46d7febd --- /dev/null +++ b/.changeset/fn-6767-agents-sidebar.md @@ -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. diff --git a/packages/dashboard/app/components/AgentsView.css b/packages/dashboard/app/components/AgentsView.css index 57b72b6348..ec229e3500 100644 --- a/packages/dashboard/app/components/AgentsView.css +++ b/packages/dashboard/app/components/AgentsView.css @@ -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); - } -} diff --git a/packages/dashboard/app/components/AgentsView.tsx b/packages/dashboard/app/components/AgentsView.tsx index df64775462..78542d61a2 100644 --- a/packages/dashboard/app/components/AgentsView.tsx +++ b/packages/dashboard/app/components/AgentsView.tsx @@ -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(() => readAgentsSidebarWidth(projectId)); const [filterState, setFilterState] = useState("all"); const { agents, stats, isLoading, loadAgents, refreshAgents } = useAgents(projectId, { filterState, @@ -320,6 +343,10 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin const controlsTriggerRef = useRef(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) => { + 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) => { + 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(null); const roleSelectRef = useRef(null); const [updatingHeartbeatAgentId, setUpdatingHeartbeatAgentId] = useState(null); @@ -1547,7 +1627,10 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin ) : ( -
+
{/* Agent Collection */} @@ -1969,6 +2052,22 @@ export function AgentsView({ addToast, projectId, onOpenTaskLogs, agentOnboardin
+ {!isMobileViewport && ( +
+ )} +
{selectedAgentId ? ( diff --git a/packages/dashboard/app/components/__tests__/AgentsView.test.tsx b/packages/dashboard/app/components/__tests__/AgentsView.test.tsx index 1cbb942556..283b39987d 100644 --- a/packages/dashboard/app/components/__tests__/AgentsView.test.tsx +++ b/packages/dashboard/app/components/__tests__/AgentsView.test.tsx @@ -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(); + + 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(".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(); + + await waitFor(() => { + expect(screen.getByText("Agents")).toBeTruthy(); + }); + + expect(screen.queryByTestId("agents-sidebar-resize-handle")).toBeNull(); + expect(container.querySelector(".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(); + + const handle = await screen.findByTestId("agents-sidebar-resize-handle"); + expect(handle).toHaveAttribute("aria-valuenow", String(expected)); + expect(container.querySelector(".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(); + + 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(); + + 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(); + 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(".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(); + 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(); @@ -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(); + + 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(); diff --git a/packages/dashboard/app/utils/__tests__/projectStorage.test.ts b/packages/dashboard/app/utils/__tests__/projectStorage.test.ts index aef6e5fa2e..79edf12c11 100644 --- a/packages/dashboard/app/utils/__tests__/projectStorage.test.ts +++ b/packages/dashboard/app/utils/__tests__/projectStorage.test.ts @@ -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", () => { diff --git a/packages/dashboard/app/utils/projectStorage.ts b/packages/dashboard/app/utils/projectStorage.ts index a7418c9a6d..8837b370db 100644 --- a/packages/dashboard/app/utils/projectStorage.ts +++ b/packages/dashboard/app/utils/projectStorage.ts @@ -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",