authorgravatar for gianmarcosimone89@gmail.comGianmarco <gianmarcosimone89@gmail.com> 2026-09-22 18:46:41+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-22 11:46:41-05:00
logda6a85b1365993d0ff2a79698cd82498de247d8a
tree60926c78fc591a79abe48b2b0288995d9a96ecac
parentaff9318bf46beaf05cc7155b428d3f0b8711efd2
signature Signed by PGP key B5690EEEBB952194

fix(web): respect panel motion in composer transitions (#11064)


5 files changed, 93 insertions(+), 36 deletions(-)

apps/web/src/components/ChatView.tsx+26-16
......@@ -406,7 +406,6 @@ import {
406406import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow";
407407import {
408408 DRAFT_HERO_TRANSITION_ANIMATION_ID,
409 DRAFT_HERO_TRANSITION_DURATION_MS,
410409 DRAFT_HERO_TRANSITION_EASING,
411410 MOBILE_COMPOSER_VIEW_TRANSITION_NAME,
412411 MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME,
......@@ -539,7 +538,11 @@ const EMPTY_PROVIDERS: ServerProvider[] = [];
539538const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = [];
540539const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = [];
541540const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {};
542function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
541function useDraftHeroLayoutTransition(
542 isDraftHeroState: boolean,
543 animationsActive: boolean,
544 animationDurationMs: number,
545) {
543546 const transitionGroupRef = useRef<HTMLDivElement | null>(null);
544547 const composerAnchorRef = useRef<HTMLDivElement | null>(null);
545548 const previousStateRef = useRef(isDraftHeroState);
......@@ -559,9 +562,6 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
559562 const transitionGroup = transitionGroupRef.current;
560563 const nextComposerRect = composerAnchorRef.current?.getBoundingClientRect() ?? null;
561564 const stateChanged = previousStateRef.current !== isDraftHeroState;
562 const prefersReducedMotion =
563 typeof window !== "undefined" &&
564 window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
565565 const mobileComposerTransitionActive =
566566 typeof document !== "undefined" &&
567567 document.documentElement.dataset.mobileComposerRouteTransition === "true";
......@@ -572,7 +572,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
572572 const previousComposerRect = previousComposerRectRef.current;
573573 if (
574574 stateChanged &&
575 !prefersReducedMotion &&
575 animationsActive &&
576576 !mobileComposerTransitionActive &&
577577 transitionGroup &&
578578 previousComposerRect &&
......@@ -588,7 +588,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
588588 { transform: "translate3d(0, 0, 0)" },
589589 ],
590590 {
591 duration: DRAFT_HERO_TRANSITION_DURATION_MS,
591 duration: animationDurationMs,
592592 easing: DRAFT_HERO_TRANSITION_EASING,
593593 },
594594 );
......@@ -607,7 +607,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
607607
608608 previousStateRef.current = isDraftHeroState;
609609 previousComposerRectRef.current = nextComposerRect;
610 }, [isDraftHeroState]);
610 }, [animationDurationMs, animationsActive, isDraftHeroState]);
611611
612612 return [attachTransitionGroupRef, attachComposerAnchorRef, captureComposerRect] as const;
613613}
......@@ -3634,7 +3634,11 @@ export default function ChatView(props: ChatViewProps) {
36343634 attachDraftHeroTransitionGroupRef,
36353635 attachDraftHeroComposerAnchorRef,
36363636 captureDraftHeroComposerRect,
3637 ] = useDraftHeroLayoutTransition(isDraftHeroState);
3637 ] = useDraftHeroLayoutTransition(
3638 isDraftHeroState,
3639 panelAnimationsActive,
3640 panelAnimationDurationMs,
3641 );
36383642
36393643 const gitCwd = activeProject
36403644 ? projectScriptCwd({
......@@ -7877,13 +7881,19 @@ export default function ChatView(props: ChatViewProps) {
78777881 const dockStarted = new Promise<void>((resolve) => {
78787882 resolveDockStarted = resolve;
78797883 });
7880 const dockTransition = runMobileComposerTransition(() => {
7881 flushSync(() => {
7882 captureDraftHeroComposerRect();
7883 setDockedDraftHeroThreadKey(activeThreadKey);
7884 });
7885 resolveDockStarted?.();
7886 });
7884 const dockTransition = runMobileComposerTransition(
7885 () => {
7886 flushSync(() => {
7887 captureDraftHeroComposerRect();
7888 setDockedDraftHeroThreadKey(activeThreadKey);
7889 });
7890 resolveDockStarted?.();
7891 },
7892 {
7893 active: panelAnimationsActive,
7894 durationMs: panelAnimationDurationMs,
7895 },
7896 );
78877897 void dockTransition.catch(() => resolveDockStarted?.());
78887898 await dockStarted;
78897899 }
apps/web/src/components/chat/ChatComposer.tsx+14-8
......@@ -395,7 +395,6 @@ const EMPTY_PULL_REQUEST_LIST_TARGETS: ReadonlyArray<EnvironmentQueryTarget<Pull
395395
396396const COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX = 24;
397397const COMPOSER_SCROLL_GESTURE_RESET_MS = 120;
398const COMPOSER_RESTING_TRANSITION_DURATION_MS = 280;
399398const COMPOSER_RESTING_TRANSITION_CLEANUP_BUFFER_MS = 50;
400399const COMPOSER_RESTING_TRANSITION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)";
401400const COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX = 4;
......@@ -405,6 +404,8 @@ function useComposerRestingTransition(
405404 isResting: boolean,
406405 restingControlsRef: React.RefObject<HTMLDivElement | null>,
407406 onOverlayHeightChange: (height: number) => void,
407 animationsActive: boolean,
408 animationDurationMs: number,
408409) {
409410 const elementRef = useRef<HTMLDivElement>(null);
410411 const isCollapsedRef = useRef(isCollapsed);
......@@ -523,7 +524,6 @@ function useComposerRestingTransition(
523524 const previousHeight = interruptedHeight ?? previousHeightRef.current;
524525 const targetChanged =
525526 interruptedTargetHeight === null || Math.abs(interruptedTargetHeight - nextHeight) >= 0.5;
526 const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
527527 const shouldAnimate = shouldAnimateComposerRestingTransition({
528528 hasCompletedInitialLayout: hasCompletedInitialLayoutRef.current,
529529 stateChanged,
......@@ -532,18 +532,16 @@ function useComposerRestingTransition(
532532
533533 if (
534534 shouldAnimate &&
535 !prefersReducedMotion &&
535 animationsActive &&
536536 previousHeight !== null &&
537537 Math.abs(previousHeight - nextHeight) >= 0.5
538538 ) {
539539 const remainingDuration =
540540 typeof interruptedDuration === "number" && interruptedCurrentTime !== null
541541 ? Math.max(1, interruptedDuration - interruptedCurrentTime)
542 : COMPOSER_RESTING_TRANSITION_DURATION_MS;
542 : animationDurationMs;
543543 const duration =
544 interruptedHeight !== null && !targetChanged
545 ? remainingDuration
546 : COMPOSER_RESTING_TRANSITION_DURATION_MS;
544 interruptedHeight !== null && !targetChanged ? remainingDuration : animationDurationMs;
547545 element.style.overflow = "clip";
548546 surface.style.height = "100%";
549547
......@@ -743,7 +741,13 @@ function useComposerRestingTransition(
743741 actionFromBottom: nextActionTop === null ? null : nextRect.bottom - nextActionTop,
744742 };
745743 },
746 [clearTransitionStyles, onOverlayHeightChange, restingControlsRef],
744 [
745 animationDurationMs,
746 animationsActive,
747 clearTransitionStyles,
748 onOverlayHeightChange,
749 restingControlsRef,
750 ],
747751 );
748752
749753 useLayoutEffect(() => {
......@@ -4826,6 +4830,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
48264830 isComposerResting,
48274831 restingComposerControlsRef,
48284832 onComposerOverlayHeightChange,
4833 panelAnimationsActive,
4834 panelAnimationDurationMs,
48294835 );
48304836 const canTrackComposerScrollGesture =
48314837 routeKind === "server" && activeThreadId !== null && !isMobileViewport;
apps/web/src/components/chat/draftHeroTransition.test.ts+32-5
......@@ -56,8 +56,9 @@ describe("runMobileComposerTransition", () => {
5656 finishTransition = resolve;
5757 });
5858 const dataset: Record<string, string> = {};
59 const style = { setProperty: vi.fn(), removeProperty: vi.fn() };
5960 vi.stubGlobal("document", {
60 documentElement: { dataset },
61 documentElement: { dataset, style },
6162 getAnimations: () => [],
6263 startViewTransition: (update: () => void | Promise<void>) => {
6364 void update();
......@@ -68,7 +69,10 @@ describe("runMobileComposerTransition", () => {
6869 matchMedia: (query: string) => ({ matches: query === "(max-width: 639px)" }),
6970 });
7071
71 const transition = runMobileComposerTransition(() => undefined);
72 const transition = runMobileComposerTransition(() => undefined, {
73 active: true,
74 durationMs: 240,
75 });
7276 await Promise.resolve();
7377
7478 let handoffComplete = false;
......@@ -85,11 +89,12 @@ describe("runMobileComposerTransition", () => {
8589
8690 it("uses a scoped view transition on mobile", async () => {
8791 const dataset: Record<string, string> = {};
92 const style = { setProperty: vi.fn(), removeProperty: vi.fn() };
8893 const startViewTransition = vi.fn((update: () => void | Promise<void>) => ({
8994 finished: Promise.resolve(update()).then(() => undefined),
9095 }));
9196 vi.stubGlobal("document", {
92 documentElement: { dataset },
97 documentElement: { dataset, style },
9398 startViewTransition,
9499 });
95100 vi.stubGlobal("window", {
......@@ -97,13 +102,35 @@ describe("runMobileComposerTransition", () => {
97102 });
98103 const update = vi.fn();
99104
100 await runMobileComposerTransition(update);
105 await runMobileComposerTransition(update, { active: true, durationMs: 360 });
101106
102107 expect(startViewTransition).toHaveBeenCalledOnce();
103108 expect(update).toHaveBeenCalledOnce();
109 expect(style.setProperty).toHaveBeenCalledWith(
110 "--mobile-composer-transition-duration",
111 "360ms",
112 );
113 expect(style.removeProperty).toHaveBeenCalledWith("--mobile-composer-transition-duration");
104114 expect(dataset).not.toHaveProperty("mobileComposerRouteTransition");
105115 });
106116
117 it("updates without a view transition when panel animations are inactive", async () => {
118 const startViewTransition = vi.fn();
119 vi.stubGlobal("document", {
120 documentElement: { dataset: {} },
121 startViewTransition,
122 });
123 vi.stubGlobal("window", {
124 matchMedia: () => ({ matches: true }),
125 });
126 const update = vi.fn();
127
128 await runMobileComposerTransition(update, { active: false, durationMs: 360 });
129
130 expect(startViewTransition).not.toHaveBeenCalled();
131 expect(update).toHaveBeenCalledOnce();
132 });
133
107134 it("updates without a view transition when reduced motion is preferred", async () => {
108135 const startViewTransition = vi.fn();
109136 vi.stubGlobal("document", {
......@@ -115,7 +142,7 @@ describe("runMobileComposerTransition", () => {
115142 });
116143 const update = vi.fn();
117144
118 await runMobileComposerTransition(update);
145 await runMobileComposerTransition(update, { active: true, durationMs: 360 });
119146
120147 expect(startViewTransition).not.toHaveBeenCalled();
121148 expect(update).toHaveBeenCalledOnce();
apps/web/src/components/chat/draftHeroTransition.ts+15-2
......@@ -1,8 +1,8 @@
11export const DRAFT_HERO_TRANSITION_ANIMATION_ID = "t3-draft-hero-transition";
2export const DRAFT_HERO_TRANSITION_DURATION_MS = 180;
32export const DRAFT_HERO_TRANSITION_EASING = "cubic-bezier(0.4, 0, 0.2, 1)";
43export const MOBILE_COMPOSER_VIEW_TRANSITION_NAME = "t3-mobile-composer";
54export const MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME = "t3-mobile-draft-headline";
5const MOBILE_COMPOSER_TRANSITION_DURATION_PROPERTY = "--mobile-composer-transition-duration";
66
77type ComposerViewTransition = {
88 readonly finished: Promise<void>;
......@@ -39,6 +39,7 @@ export async function waitForDraftHeroTransition(): Promise<void> {
3939
4040export async function runMobileComposerTransition(
4141 update: () => void | Promise<void>,
42 options: { active: boolean; durationMs: number },
4243): Promise<void> {
4344 if (typeof document === "undefined" || typeof window === "undefined") {
4445 await update();
......@@ -49,7 +50,12 @@ export async function runMobileComposerTransition(
4950 const mobileViewport = window.matchMedia?.("(max-width: 639px)").matches ?? false;
5051 const prefersReducedMotion =
5152 window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false;
52 if (!mobileViewport || prefersReducedMotion || !transitionDocument.startViewTransition) {
53 if (
54 !options.active ||
55 !mobileViewport ||
56 prefersReducedMotion ||
57 !transitionDocument.startViewTransition
58 ) {
5359 await update();
5460 return;
5561 }
......@@ -61,6 +67,10 @@ export async function runMobileComposerTransition(
6167 await update();
6268 };
6369 let transitionFinished: Promise<void> | null = null;
70 transitionDocument.documentElement.style.setProperty(
71 MOBILE_COMPOSER_TRANSITION_DURATION_PROPERTY,
72 `${String(options.durationMs)}ms`,
73 );
6474 transitionDocument.documentElement.dataset.mobileComposerRouteTransition = "true";
6575 try {
6676 const transition = transitionDocument.startViewTransition(runUpdate);
......@@ -78,5 +88,8 @@ export async function runMobileComposerTransition(
7888 activeMobileComposerTransition = null;
7989 }
8090 delete transitionDocument.documentElement.dataset.mobileComposerRouteTransition;
91 transitionDocument.documentElement.style.removeProperty(
92 MOBILE_COMPOSER_TRANSITION_DURATION_PROPERTY,
93 );
8194 }
8295}
apps/web/src/index.css+6-5
......@@ -13,7 +13,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-new(root) {
1313}
1414
1515html[data-mobile-composer-route-transition="true"]::view-transition-group(t3-mobile-composer) {
16 animation-duration: 180ms;
16 animation-duration: var(--mobile-composer-transition-duration);
1717 animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
1818}
1919
......@@ -22,12 +22,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-image-pair(t
2222}
2323
2424html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobile-composer) {
25 animation: t3-mobile-composer-old 180ms linear both;
25 animation: t3-mobile-composer-old var(--mobile-composer-transition-duration) linear both;
2626 mix-blend-mode: normal;
2727}
2828
2929html[data-mobile-composer-route-transition="true"]::view-transition-new(t3-mobile-composer) {
30 animation: t3-mobile-composer-new 180ms linear both;
30 animation: t3-mobile-composer-new var(--mobile-composer-transition-duration) linear both;
3131 mix-blend-mode: normal;
3232}
3333
......@@ -57,11 +57,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-new(t3-mobil
5757}
5858
5959html[data-mobile-composer-route-transition="true"]::view-transition-group(t3-mobile-draft-headline) {
60 animation-duration: 130ms;
60 animation-duration: var(--mobile-composer-transition-duration);
6161}
6262
6363html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobile-draft-headline) {
64 animation: t3-mobile-draft-headline-exit 130ms cubic-bezier(0.4, 0, 1, 1) both;
64 animation: t3-mobile-draft-headline-exit var(--mobile-composer-transition-duration)
65 cubic-bezier(0.4, 0, 1, 1) both;
6566 mix-blend-mode: normal;
6667}
6768