diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 2bcbd140a0..21eec82b8e 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -110,6 +110,8 @@ declare module '@tanstack/react-router' { icon?: LucideIcon; /** Render the route with minimal chrome (no page header/footer/padding). */ fullscreen?: boolean; + /** Route has its own title bar: the app header shows only the breadcrumb row. */ + breadcrumbOnlyHeader?: boolean; } } diff --git a/frontend/src/components/layout/footer.tsx b/frontend/src/components/layout/footer.tsx index ddaa52f8bf..0707ec0e65 100644 --- a/frontend/src/components/layout/footer.tsx +++ b/frontend/src/components/layout/footer.tsx @@ -11,6 +11,7 @@ import { useLocation, useMatchRoute } from '@tanstack/react-router'; import { GitHubIcon, SlackIcon, TwitterIcon } from 'components/icons'; +import { useLayoutEffect, useRef } from 'react'; import { isEmbedded } from '../../config'; import env, { getBuildDate, IsCI, IsDev } from '../../utils/env'; @@ -54,17 +55,87 @@ export const VersionInfo = () => { ); }; +/** Distance from the document top to the element, scroll-independent. */ +const measureDocumentTop = (target: HTMLElement): number => { + let top = 0; + for (let el: HTMLElement | null = target; el; ) { + top += el.offsetTop; + el = el.offsetParent instanceof HTMLElement ? el.offsetParent : null; + } + return top; +}; + +/** Space the ancestors (up to and including ) keep below the element. */ +const measureReservedBelow = (target: HTMLElement): number => { + let reserved = 0; + for (let el: HTMLElement | null = target; el && el !== document.documentElement; el = el.parentElement) { + const style = getComputedStyle(el); + reserved += Number.parseFloat(style.marginBottom) || 0; + if (el !== target) { + reserved += (Number.parseFloat(style.paddingBottom) || 0) + (Number.parseFloat(style.borderBottomWidth) || 0); + } + } + return reserved; +}; + +// Measured rather than a CSS flex chain: in embedded mode the wrappers above +// #mainLayout belong to the host app. +const stretchLayoutToViewportBottom = (layoutEl: HTMLElement) => { + const viewportHeight = document.documentElement.clientHeight; + const minHeight = Math.round(viewportHeight - measureDocumentTop(layoutEl) - measureReservedBelow(layoutEl)); + const value = minHeight > 0 ? `${minHeight}px` : ''; + if (layoutEl.style.minHeight !== value) { + layoutEl.style.minHeight = value; + } +}; + +/** Pins the footer to the viewport bottom on short pages by stretching `#mainLayout`. */ +const useBottomPinnedFooter = (hidden: boolean) => { + const footerRef = useRef(null); + + useLayoutEffect(() => { + const layoutEl = hidden ? null : footerRef.current?.closest('#mainLayout'); + if (!layoutEl) { + return; + } + + const update = () => stretchLayoutToViewportBottom(layoutEl); + + update(); + const observer = new ResizeObserver(update); + observer.observe(document.documentElement); + observer.observe(layoutEl); + // Re-measure when useCancelHostGutters adjusts the layout's margins post-mount. + const mutationObserver = new MutationObserver(update); + mutationObserver.observe(layoutEl, { attributeFilter: ['style'], attributes: true }); + window.addEventListener('resize', update); + return () => { + observer.disconnect(); + mutationObserver.disconnect(); + window.removeEventListener('resize', update); + layoutEl.style.minHeight = ''; + }; + }, [hidden]); + + return footerRef; +}; + export const AppFooter = () => { const location = useLocation(); const matchRoute = useMatchRoute(); const isAgentPage = matchRoute({ to: '/agents/$id' }); // Hide footer on AI agent inspector tab + let hidden = false; if (isAgentPage) { const searchParams = new URLSearchParams(location.searchStr ?? ''); - if (searchParams.get('tab') === 'inspector') { - return null; - } + hidden = searchParams.get('tab') === 'inspector'; + } + + const footerRef = useBottomPinnedFooter(hidden); + + if (hidden) { + return null; } const gitHub = (link: string, title: string) => ( @@ -76,7 +147,7 @@ export const AppFooter = () => { ); return ( -