From b5376a665a8b2ae219d786ae4cd151971f9c8507 Mon Sep 17 00:00:00 2001 From: Managed via Tart Date: Tue, 19 May 2026 10:02:46 +0000 Subject: [PATCH] Fix activity center collapse anchoring Co-authored-by: 3720 --- .../messages/activity-center/banner.tsx | 77 +++++++++++++++---- .../messages/activity-center/layout.ts | 47 +++++++++++ .../embedded/activity-center-banner.test.ts | 53 +++++++++++++ 3 files changed, 161 insertions(+), 16 deletions(-) create mode 100644 web/src/embedded/chat/components/messages/activity-center/layout.ts create mode 100644 web/tests/embedded/activity-center-banner.test.ts diff --git a/web/src/embedded/chat/components/messages/activity-center/banner.tsx b/web/src/embedded/chat/components/messages/activity-center/banner.tsx index cbebca6..e0a4433 100644 --- a/web/src/embedded/chat/components/messages/activity-center/banner.tsx +++ b/web/src/embedded/chat/components/messages/activity-center/banner.tsx @@ -4,6 +4,7 @@ import { AnimatePresence, motion, type HTMLMotionProps } from 'motion/react'; import { useCallback, useEffect, + useLayoutEffect, useRef, useState, type ReactNode, @@ -12,12 +13,18 @@ import { createPortal } from 'react-dom'; import { MaskedScrollArea } from '../../masked-scrollarea'; import { useMacosVersion } from '@/utils/use-utils-bridge'; import { commitGlobalCSSVar } from '@/embedded/chat/global-css-var'; +import { + resolveActivityCenterAnchorHeight, + resolveActivityCenterExitY, + shouldPadActivityCenterSafeArea, +} from './layout'; export const ActivityCenterBanner = ({ header, children, visible, className, + style, ...props }: HTMLMotionProps<'div'> & { header: (expanded: boolean) => ReactNode; @@ -27,34 +34,64 @@ export const ActivityCenterBanner = ({ const ref = useRef(null); const macosVersion = useMacosVersion(); const [open, setOpen] = useState(true); + const [anchorHeight, setAnchorHeight] = useState(0); + const [measuredVisibleHeight, setMeasuredVisibleHeight] = useState(0); // banner enter with a spring animation, // add extra space to avoid bottom side bounce into viewport const safeArea = 60; const headerHeight = 36; - const commitHeight = useCallback((height: number) => { - commitGlobalCSSVar( - 'activityCenterHeight', - `${(height ? height - safeArea : 0).toFixed(0)}px` - ); - }, []); + const usesSafeAreaPadding = shouldPadActivityCenterSafeArea({ + anchorHeight, + measuredVisibleHeight, + }); const detectHeight = useCallback(() => { const el = ref.current; if (!el || !visible) { - commitHeight(0); + setMeasuredVisibleHeight(0); + setAnchorHeight(0); return; } + + const paddingBottom = + parseFloat(window.getComputedStyle(el).paddingBottom) || 0; const height = el.getBoundingClientRect().height; - commitHeight(height); - }, [visible, commitHeight]); + const nextMeasuredVisibleHeight = Math.max(0, height - paddingBottom); + + setMeasuredVisibleHeight(nextMeasuredVisibleHeight); + setAnchorHeight(previousAnchorHeight => + resolveActivityCenterAnchorHeight({ + previousAnchorHeight, + measuredVisibleHeight: nextMeasuredVisibleHeight, + isOpen: open, + }) + ); + }, [open, visible]); // const debouncedDetectHeight = useMemo( // () => debounce(detectHeight, 100), // [detectHeight] // ); + useLayoutEffect(() => { + detectHeight(); + }, [detectHeight]); + + useEffect(() => { + commitGlobalCSSVar( + 'activityCenterHeight', + `${(visible ? anchorHeight : 0).toFixed(0)}px` + ); + }, [anchorHeight, visible]); + + useEffect(() => { + return () => { + commitGlobalCSSVar('activityCenterHeight', '0px'); + }; + }, []); + useEffect(() => { if (!visible) { detectHeight(); @@ -64,10 +101,7 @@ export const ActivityCenterBanner = ({ if (!el) return; const dispose = observeResize(el, detectHeight); - return () => { - detectHeight(); - dispose(); - }; + return dispose; }, [visible, detectHeight]); return createPortal( @@ -75,7 +109,9 @@ export const ActivityCenterBanner = ({ {visible && ( 0 + ? { top: `calc(100dvh - ${anchorHeight.toFixed(0)}px)` } + : { bottom: -safeArea }), + ...style, + }} {...props} ref={ref} > @@ -126,9 +168,12 @@ export const ActivityCenterBanner = ({ {/* Shadow below the banner */}
)} diff --git a/web/src/embedded/chat/components/messages/activity-center/layout.ts b/web/src/embedded/chat/components/messages/activity-center/layout.ts new file mode 100644 index 0000000..3bad97f --- /dev/null +++ b/web/src/embedded/chat/components/messages/activity-center/layout.ts @@ -0,0 +1,47 @@ +export const resolveActivityCenterAnchorHeight = ({ + previousAnchorHeight, + measuredVisibleHeight, + isOpen, +}: { + previousAnchorHeight: number; + measuredVisibleHeight: number; + isOpen: boolean; +}) => { + if (measuredVisibleHeight <= 0) { + return 0; + } + + if (isOpen) { + return measuredVisibleHeight; + } + + if (!isOpen && previousAnchorHeight > 0) { + return previousAnchorHeight; + } + + return measuredVisibleHeight; +}; + +export const shouldPadActivityCenterSafeArea = ({ + anchorHeight, + measuredVisibleHeight, +}: { + anchorHeight: number; + measuredVisibleHeight: number; +}) => anchorHeight <= 0 || measuredVisibleHeight >= anchorHeight - 0.5; + +export const resolveActivityCenterExitY = ({ + anchorHeight, + safeArea, + exitOffset = 20, +}: { + anchorHeight: number; + safeArea: number; + exitOffset?: number; +}) => { + if (anchorHeight <= 0) { + return `calc(100% + ${exitOffset}px)`; + } + + return anchorHeight + safeArea + exitOffset; +}; diff --git a/web/tests/embedded/activity-center-banner.test.ts b/web/tests/embedded/activity-center-banner.test.ts new file mode 100644 index 0000000..8dee4bc --- /dev/null +++ b/web/tests/embedded/activity-center-banner.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { + resolveActivityCenterAnchorHeight, + resolveActivityCenterExitY, + shouldPadActivityCenterSafeArea, +} from '../../src/embedded/chat/components/messages/activity-center/layout'; + +describe('ActivityCenterBanner layout anchoring', () => { + it('keeps the expanded anchor height while the banner is collapsing', () => { + expect( + resolveActivityCenterAnchorHeight({ + previousAnchorHeight: 240, + measuredVisibleHeight: 36, + isOpen: false, + }) + ).toBe(240); + }); + + it('grows the anchor when open content becomes taller', () => { + expect( + resolveActivityCenterAnchorHeight({ + previousAnchorHeight: 120, + measuredVisibleHeight: 180, + isOpen: true, + }) + ).toBe(180); + }); + + it('shrinks the anchor when open content becomes shorter', () => { + expect( + resolveActivityCenterAnchorHeight({ + previousAnchorHeight: 240, + measuredVisibleHeight: 120, + isOpen: true, + }) + ).toBe(120); + }); + + it('does not expose bottom safe-area padding after top-anchored collapse', () => { + expect( + shouldPadActivityCenterSafeArea({ + anchorHeight: 240, + measuredVisibleHeight: 36, + }) + ).toBe(false); + }); + + it('pushes a top-anchored collapsed banner below the viewport on exit', () => { + expect( + resolveActivityCenterExitY({ anchorHeight: 240, safeArea: 60 }) + ).toBe(320); + }); +});