From 50f1602183b8d3c01be48b79ef76949e75f78f82 Mon Sep 17 00:00:00 2001 From: Gianmarco Simone Date: Thu, 10 Sep 2026 12:03:49 +0200 Subject: [PATCH] fix(web): respect panel motion in composer transitions --- apps/web/src/components/ChatView.tsx | 42 ++++++++++++------- apps/web/src/components/chat/ChatComposer.tsx | 22 ++++++---- .../chat/draftHeroTransition.test.ts | 37 +++++++++++++--- .../components/chat/draftHeroTransition.ts | 17 +++++++- apps/web/src/index.css | 11 ++--- 5 files changed, 93 insertions(+), 36 deletions(-) diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 51b9c5eabc48..f5726c8523e0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -367,7 +367,6 @@ import { import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow"; import { DRAFT_HERO_TRANSITION_ANIMATION_ID, - DRAFT_HERO_TRANSITION_DURATION_MS, DRAFT_HERO_TRANSITION_EASING, MOBILE_COMPOSER_VIEW_TRANSITION_NAME, MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME, @@ -490,7 +489,11 @@ const EMPTY_PROVIDERS: ServerProvider[] = []; const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = []; const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; -function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { +function useDraftHeroLayoutTransition( + isDraftHeroState: boolean, + animationsActive: boolean, + animationDurationMs: number, +) { const transitionGroupRef = useRef(null); const composerAnchorRef = useRef(null); const previousStateRef = useRef(isDraftHeroState); @@ -510,9 +513,6 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const transitionGroup = transitionGroupRef.current; const nextComposerRect = composerAnchorRef.current?.getBoundingClientRect() ?? null; const stateChanged = previousStateRef.current !== isDraftHeroState; - const prefersReducedMotion = - typeof window !== "undefined" && - window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; const mobileComposerTransitionActive = typeof document !== "undefined" && document.documentElement.dataset.mobileComposerRouteTransition === "true"; @@ -523,7 +523,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { const previousComposerRect = previousComposerRectRef.current; if ( stateChanged && - !prefersReducedMotion && + animationsActive && !mobileComposerTransitionActive && transitionGroup && previousComposerRect && @@ -539,7 +539,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { { transform: "translate3d(0, 0, 0)" }, ], { - duration: DRAFT_HERO_TRANSITION_DURATION_MS, + duration: animationDurationMs, easing: DRAFT_HERO_TRANSITION_EASING, }, ); @@ -558,7 +558,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { previousStateRef.current = isDraftHeroState; previousComposerRectRef.current = nextComposerRect; - }, [isDraftHeroState]); + }, [animationDurationMs, animationsActive, isDraftHeroState]); return [attachTransitionGroupRef, attachComposerAnchorRef, captureComposerRect] as const; } @@ -3275,7 +3275,11 @@ export default function ChatView(props: ChatViewProps) { attachDraftHeroTransitionGroupRef, attachDraftHeroComposerAnchorRef, captureDraftHeroComposerRect, - ] = useDraftHeroLayoutTransition(isDraftHeroState); + ] = useDraftHeroLayoutTransition( + isDraftHeroState, + panelAnimationsActive, + panelAnimationDurationMs, + ); const gitCwd = activeProject ? projectScriptCwd({ @@ -6996,13 +7000,19 @@ export default function ChatView(props: ChatViewProps) { const dockStarted = new Promise((resolve) => { resolveDockStarted = resolve; }); - const dockTransition = runMobileComposerTransition(() => { - flushSync(() => { - captureDraftHeroComposerRect(); - setDockedDraftHeroThreadKey(activeThreadKey); - }); - resolveDockStarted?.(); - }); + const dockTransition = runMobileComposerTransition( + () => { + flushSync(() => { + captureDraftHeroComposerRect(); + setDockedDraftHeroThreadKey(activeThreadKey); + }); + resolveDockStarted?.(); + }, + { + active: panelAnimationsActive, + durationMs: panelAnimationDurationMs, + }, + ); void dockTransition.catch(() => resolveDockStarted?.()); await dockStarted; } diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 7ecb5e2dc635..6c6b63aa561f 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -312,7 +312,6 @@ function SnapShotAttachmentFrame({ const COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX = 24; const COMPOSER_SCROLL_GESTURE_RESET_MS = 120; -const COMPOSER_RESTING_TRANSITION_DURATION_MS = 280; const COMPOSER_RESTING_TRANSITION_CLEANUP_BUFFER_MS = 50; const COMPOSER_RESTING_TRANSITION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; const COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX = 4; @@ -322,6 +321,8 @@ function useComposerRestingTransition( isResting: boolean, restingControlsRef: React.RefObject, onOverlayHeightChange: (height: number) => void, + animationsActive: boolean, + animationDurationMs: number, ) { const elementRef = useRef(null); const isCollapsedRef = useRef(isCollapsed); @@ -440,7 +441,6 @@ function useComposerRestingTransition( const previousHeight = interruptedHeight ?? previousHeightRef.current; const targetChanged = interruptedTargetHeight === null || Math.abs(interruptedTargetHeight - nextHeight) >= 0.5; - const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; const shouldAnimate = shouldAnimateComposerRestingTransition({ hasCompletedInitialLayout: hasCompletedInitialLayoutRef.current, stateChanged, @@ -449,18 +449,16 @@ function useComposerRestingTransition( if ( shouldAnimate && - !prefersReducedMotion && + animationsActive && previousHeight !== null && Math.abs(previousHeight - nextHeight) >= 0.5 ) { const remainingDuration = typeof interruptedDuration === "number" && interruptedCurrentTime !== null ? Math.max(1, interruptedDuration - interruptedCurrentTime) - : COMPOSER_RESTING_TRANSITION_DURATION_MS; + : animationDurationMs; const duration = - interruptedHeight !== null && !targetChanged - ? remainingDuration - : COMPOSER_RESTING_TRANSITION_DURATION_MS; + interruptedHeight !== null && !targetChanged ? remainingDuration : animationDurationMs; element.style.overflow = "clip"; surface.style.height = "100%"; @@ -660,7 +658,13 @@ function useComposerRestingTransition( actionFromBottom: nextActionTop === null ? null : nextRect.bottom - nextActionTop, }; }, - [clearTransitionStyles, onOverlayHeightChange, restingControlsRef], + [ + animationDurationMs, + animationsActive, + clearTransitionStyles, + onOverlayHeightChange, + restingControlsRef, + ], ); useLayoutEffect(() => { @@ -3946,6 +3950,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) isComposerResting, restingComposerControlsRef, onComposerOverlayHeightChange, + panelAnimationsActive, + panelAnimationDurationMs, ); const canTrackComposerScrollGesture = routeKind === "server" && activeThreadId !== null && !isMobileViewport; diff --git a/apps/web/src/components/chat/draftHeroTransition.test.ts b/apps/web/src/components/chat/draftHeroTransition.test.ts index c9d43d541584..c8a708e3b616 100644 --- a/apps/web/src/components/chat/draftHeroTransition.test.ts +++ b/apps/web/src/components/chat/draftHeroTransition.test.ts @@ -56,8 +56,9 @@ describe("runMobileComposerTransition", () => { finishTransition = resolve; }); const dataset: Record = {}; + const style = { setProperty: vi.fn(), removeProperty: vi.fn() }; vi.stubGlobal("document", { - documentElement: { dataset }, + documentElement: { dataset, style }, getAnimations: () => [], startViewTransition: (update: () => void | Promise) => { void update(); @@ -68,7 +69,10 @@ describe("runMobileComposerTransition", () => { matchMedia: (query: string) => ({ matches: query === "(max-width: 639px)" }), }); - const transition = runMobileComposerTransition(() => undefined); + const transition = runMobileComposerTransition(() => undefined, { + active: true, + durationMs: 240, + }); await Promise.resolve(); let handoffComplete = false; @@ -85,11 +89,12 @@ describe("runMobileComposerTransition", () => { it("uses a scoped view transition on mobile", async () => { const dataset: Record = {}; + const style = { setProperty: vi.fn(), removeProperty: vi.fn() }; const startViewTransition = vi.fn((update: () => void | Promise) => ({ finished: Promise.resolve(update()).then(() => undefined), })); vi.stubGlobal("document", { - documentElement: { dataset }, + documentElement: { dataset, style }, startViewTransition, }); vi.stubGlobal("window", { @@ -97,13 +102,35 @@ describe("runMobileComposerTransition", () => { }); const update = vi.fn(); - await runMobileComposerTransition(update); + await runMobileComposerTransition(update, { active: true, durationMs: 360 }); expect(startViewTransition).toHaveBeenCalledOnce(); expect(update).toHaveBeenCalledOnce(); + expect(style.setProperty).toHaveBeenCalledWith( + "--mobile-composer-transition-duration", + "360ms", + ); + expect(style.removeProperty).toHaveBeenCalledWith("--mobile-composer-transition-duration"); expect(dataset).not.toHaveProperty("mobileComposerRouteTransition"); }); + it("updates without a view transition when panel animations are inactive", async () => { + const startViewTransition = vi.fn(); + vi.stubGlobal("document", { + documentElement: { dataset: {} }, + startViewTransition, + }); + vi.stubGlobal("window", { + matchMedia: () => ({ matches: true }), + }); + const update = vi.fn(); + + await runMobileComposerTransition(update, { active: false, durationMs: 360 }); + + expect(startViewTransition).not.toHaveBeenCalled(); + expect(update).toHaveBeenCalledOnce(); + }); + it("updates without a view transition when reduced motion is preferred", async () => { const startViewTransition = vi.fn(); vi.stubGlobal("document", { @@ -115,7 +142,7 @@ describe("runMobileComposerTransition", () => { }); const update = vi.fn(); - await runMobileComposerTransition(update); + await runMobileComposerTransition(update, { active: true, durationMs: 360 }); expect(startViewTransition).not.toHaveBeenCalled(); expect(update).toHaveBeenCalledOnce(); diff --git a/apps/web/src/components/chat/draftHeroTransition.ts b/apps/web/src/components/chat/draftHeroTransition.ts index 0773407cfcc4..958b7374c1a8 100644 --- a/apps/web/src/components/chat/draftHeroTransition.ts +++ b/apps/web/src/components/chat/draftHeroTransition.ts @@ -1,8 +1,8 @@ export const DRAFT_HERO_TRANSITION_ANIMATION_ID = "t3-draft-hero-transition"; -export const DRAFT_HERO_TRANSITION_DURATION_MS = 180; export const DRAFT_HERO_TRANSITION_EASING = "cubic-bezier(0.4, 0, 0.2, 1)"; export const MOBILE_COMPOSER_VIEW_TRANSITION_NAME = "t3-mobile-composer"; export const MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME = "t3-mobile-draft-headline"; +const MOBILE_COMPOSER_TRANSITION_DURATION_PROPERTY = "--mobile-composer-transition-duration"; type ComposerViewTransition = { readonly finished: Promise; @@ -39,6 +39,7 @@ export async function waitForDraftHeroTransition(): Promise { export async function runMobileComposerTransition( update: () => void | Promise, + options: { active: boolean; durationMs: number }, ): Promise { if (typeof document === "undefined" || typeof window === "undefined") { await update(); @@ -49,7 +50,12 @@ export async function runMobileComposerTransition( const mobileViewport = window.matchMedia?.("(max-width: 639px)").matches ?? false; const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false; - if (!mobileViewport || prefersReducedMotion || !transitionDocument.startViewTransition) { + if ( + !options.active || + !mobileViewport || + prefersReducedMotion || + !transitionDocument.startViewTransition + ) { await update(); return; } @@ -61,6 +67,10 @@ export async function runMobileComposerTransition( await update(); }; let transitionFinished: Promise | null = null; + transitionDocument.documentElement.style.setProperty( + MOBILE_COMPOSER_TRANSITION_DURATION_PROPERTY, + `${String(options.durationMs)}ms`, + ); transitionDocument.documentElement.dataset.mobileComposerRouteTransition = "true"; try { const transition = transitionDocument.startViewTransition(runUpdate); @@ -78,5 +88,8 @@ export async function runMobileComposerTransition( activeMobileComposerTransition = null; } delete transitionDocument.documentElement.dataset.mobileComposerRouteTransition; + transitionDocument.documentElement.style.removeProperty( + MOBILE_COMPOSER_TRANSITION_DURATION_PROPERTY, + ); } } diff --git a/apps/web/src/index.css b/apps/web/src/index.css index c11b2d0f4ffe..7e0e3ec6b96f 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -13,7 +13,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-new(root) { } html[data-mobile-composer-route-transition="true"]::view-transition-group(t3-mobile-composer) { - animation-duration: 180ms; + animation-duration: var(--mobile-composer-transition-duration); animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); } @@ -22,12 +22,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-image-pair(t } html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobile-composer) { - animation: t3-mobile-composer-old 180ms linear both; + animation: t3-mobile-composer-old var(--mobile-composer-transition-duration) linear both; mix-blend-mode: normal; } html[data-mobile-composer-route-transition="true"]::view-transition-new(t3-mobile-composer) { - animation: t3-mobile-composer-new 180ms linear both; + animation: t3-mobile-composer-new var(--mobile-composer-transition-duration) linear both; mix-blend-mode: normal; } @@ -57,11 +57,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-new(t3-mobil } html[data-mobile-composer-route-transition="true"]::view-transition-group(t3-mobile-draft-headline) { - animation-duration: 130ms; + animation-duration: var(--mobile-composer-transition-duration); } html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobile-draft-headline) { - animation: t3-mobile-draft-headline-exit 130ms cubic-bezier(0.4, 0, 1, 1) both; + animation: t3-mobile-draft-headline-exit var(--mobile-composer-transition-duration) + cubic-bezier(0.4, 0, 1, 1) both; mix-blend-mode: normal; }