diff --git a/.changelog/next/fixed-issue-4145.md b/.changelog/next/fixed-issue-4145.md new file mode 100644 index 0000000000..d3af3c112b --- /dev/null +++ b/.changelog/next/fixed-issue-4145.md @@ -0,0 +1 @@ +- Data Manager (/data) no longer double-scrolls and double-pads: the route is now full-width, so the page's own header bar + scrolling body is the only scroll container diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx index 7d0ec1a652..77c4b724f3 100644 --- a/client/src/components/Layout.jsx +++ b/client/src/components/Layout.jsx @@ -474,6 +474,10 @@ export function SingleNavRow({ item, collapsed, active, badgeCount, pinned, onTo const EXACT_FULL_WIDTH_PATHS = [ '/character', '/ai', + // Data Manager is a bordered title bar over a `flex-1 overflow-auto` body, + // so it owns its own scroll. EXACT, not a prefix — a `/data` prefix would + // also swallow the `/datadog` redirect route. + '/data', '/devtools/flows', '/ask', // OpenClaw lives under the Settings nav group; it's a full-bleed diff --git a/client/src/components/Layout.test.jsx b/client/src/components/Layout.test.jsx index 10c4c07f43..2cfbc9f75f 100644 --- a/client/src/components/Layout.test.jsx +++ b/client/src/components/Layout.test.jsx @@ -199,6 +199,26 @@ describe('Layout — Game workspace scroll mode', () => { }); }); +// Data Manager renders its own bordered title bar over a `flex-1 overflow-auto` +// body, so it needs the bare full-width main. While it was missing from the +// tables it nested that scroller inside `
`'s own `overflow-auto p-4 +// md:p-6` — two scrollbars, doubled padding (#4145). +describe('Layout — Data Manager scroll mode', () => { + it('gives /data the bare full-width main, and leaves /devtools/datadog padded', async () => { + const dataManager = await renderLayout('/data'); + const dataMain = dataManager.container.querySelector('#main-content'); + expect(dataMain?.className).toContain('overflow-hidden'); + expect(dataMain?.className).not.toContain('overflow-auto'); + expect(dataMain?.className).not.toContain('p-4'); + dataManager.unmount(); + + const dataDog = await renderLayout('/devtools/datadog'); + const dataDogMain = dataDog.container.querySelector('#main-content'); + expect(dataDogMain?.className).toContain('overflow-auto'); + expect(dataDogMain?.className).toContain('p-4'); + }); +}); + describe('Layout — dynamic third-level navigation', () => { it('collapses and expands the Series and Universes children', async () => { api.listPipelineSeries.mockResolvedValue([{ id: 'series-1', name: 'Example Series' }]); @@ -252,6 +272,9 @@ describe('Layout — isFullWidthRoute classification', () => { // Apps: detail editor is full-width, but the Add App form is explicitly excluded // (it has no internal scroll container and would clip below the fold). ['/apps/create', false], ['/apps/create/', false], ['/apps/a1', true], ['/apps/a1/tab', true], + // Data Manager owns its own bar+scroll shell, and is registered EXACT so + // it can't leak onto the DataDog routes that share the `/data` prefix. + ['/data', true], ['/datadog', false], ['/devtools/datadog', false], // Whole-section prefixes, and the default for an unlisted route. ['/songbook', true], ['/', false], ])('%s -> %s', (pathname, expected) => { diff --git a/client/src/pages/DataManager.jsx b/client/src/pages/DataManager.jsx index 253f5b92d4..174549fdc2 100644 --- a/client/src/pages/DataManager.jsx +++ b/client/src/pages/DataManager.jsx @@ -444,6 +444,9 @@ export default function DataManager() { setBackups(prev => prev.filter(b => b.name !== filename)); }; + // `fullHeight` + `padded` + the `p-4` bar/body mirror the loaded shell below, + // and stay correct now that `/data` is an `isFullWidthRoute` — its `
` + // supplies neither the scroll container nor the padding. if (loading) { return ( ` is a bare + // `relative overflow-hidden` and this shell owns the only scroll region. + // `min-h-0` on the column and on the body keeps a tall body scrolling instead + // of stretching the shell past `
` (#4145). return ( -
+
{/* Header */} -
+
@@ -492,7 +499,7 @@ export default function DataManager() {
{/* Content */} -
+
{/* Summary cards */}
diff --git a/client/src/pages/DataManager.test.jsx b/client/src/pages/DataManager.test.jsx index 162870e81c..2d39dc1882 100644 --- a/client/src/pages/DataManager.test.jsx +++ b/client/src/pages/DataManager.test.jsx @@ -262,3 +262,57 @@ describe('DataManager busy categories (#3342)', () => { await waitFor(() => expect(screen.getByRole('button', { name: /Purge/ })).toBeInTheDocument()); }); }); + +// `/data` is an `isFullWidthRoute`, so Layout's `
` is a bare +// `relative overflow-hidden`: this page must supply exactly ONE scroll region +// and all of its own padding. Before #4145 the route was NOT full-width, so the +// page's shell nested inside a padded, scrolling `
` — two scrollbars and +// doubled padding. Assert the shape so a revert on either side fails here. +describe('DataManager full-width shell (#4145)', () => { + beforeEach(() => { + getDataOverview.mockReset().mockResolvedValue(overview); + getDataCategory.mockReset().mockResolvedValue({ key: 'mystery-dir', items: [] }); + }); + + it('renders a single h-full column whose only scroll container is the body', async () => { + const { container } = render(); + await waitFor(() => expect(screen.getByText(UNKNOWN_DESCRIPTION)).toBeInTheDocument()); + + const root = container.firstElementChild; + expect(root.className).toContain('h-full'); + expect(root.className).toContain('flex-col'); + // The root itself never scrolls — it fills `
` exactly. + expect(root.className).not.toMatch(/overflow-(auto|y-auto|scroll)/); + + // Exactly one scrolling region in the page shell (the category list's own + // inner `max-h-64 overflow-auto` only exists on an expanded row). + const scrollers = [...root.children].filter((el) => /overflow-auto/.test(el.className)); + expect(scrollers).toHaveLength(1); + expect(scrollers[0].className).toContain('flex-1'); + expect(scrollers[0].className).toContain('min-h-0'); + // The page owns its padding — Layout's full-width main supplies none. + expect(scrollers[0].className).toContain('p-4'); + }); + + it('keeps the header bar out of the scroll region', async () => { + const { container } = render(); + await waitFor(() => expect(screen.getByText(UNKNOWN_DESCRIPTION)).toBeInTheDocument()); + + const bar = container.firstElementChild.firstElementChild; + expect(bar).toContainElement(screen.getByRole('heading', { name: 'Data Manager' })); + expect(bar.className).toContain('shrink-0'); + expect(bar.className).not.toMatch(/overflow-(auto|y-auto|scroll)/); + }); + + it('reserves the same shell in the loading skeleton', () => { + // Never resolves — hold the page in its loading state. + getDataOverview.mockReset().mockReturnValue(new Promise(() => {})); + const { container } = render(); + + const skeleton = container.querySelector('[aria-busy="true"]'); + expect(skeleton.className).toContain('h-full'); + const skeletonScrollers = [...skeleton.children].filter((el) => /overflow-y-auto/.test(el.className)); + expect(skeletonScrollers).toHaveLength(1); + expect(skeletonScrollers[0].className).toContain('p-4'); + }); +});