| author | |
| committer | |
| log | da6a85b1365993d0ff2a79698cd82498de247d8a |
| tree | 60926c78fc591a79abe48b2b0288995d9a96ecac |
| parent | aff9318bf46beaf05cc7155b428d3f0b8711efd2 |
| signature | Signed by PGP key B5690EEEBB952194 |
5 files changed, 93 insertions(+), 36 deletions(-)
apps/web/src/components/ChatView.tsx+26-16| ... | ... | @@ -406,7 +406,6 @@ import { |
| 406 | 406 | import { deriveLatestContextWindowSnapshot, formatContextWindowTokens } from "../lib/contextWindow"; |
| 407 | 407 | import { |
| 408 | 408 | DRAFT_HERO_TRANSITION_ANIMATION_ID, |
| 409 | DRAFT_HERO_TRANSITION_DURATION_MS, | |
| 410 | 409 | DRAFT_HERO_TRANSITION_EASING, |
| 411 | 410 | MOBILE_COMPOSER_VIEW_TRANSITION_NAME, |
| 412 | 411 | MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME, |
| ... | ... | @@ -539,7 +538,11 @@ const EMPTY_PROVIDERS: ServerProvider[] = []; |
| 539 | 538 | const EMPTY_USAGE_LIMIT_SOURCES: UsageLimitSourceSnapshots = []; |
| 540 | 539 | const EMPTY_PROVIDER_SKILLS: ServerProvider["skills"] = []; |
| 541 | 540 | const EMPTY_PENDING_USER_INPUT_ANSWERS: Record<string, PendingUserInputDraftAnswer> = {}; |
| 542 | function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { | |
| 541 | function useDraftHeroLayoutTransition( | |
| 542 | isDraftHeroState: boolean, | |
| 543 | animationsActive: boolean, | |
| 544 | animationDurationMs: number, | |
| 545 | ) { | |
| 543 | 546 | const transitionGroupRef = useRef<HTMLDivElement | null>(null); |
| 544 | 547 | const composerAnchorRef = useRef<HTMLDivElement | null>(null); |
| 545 | 548 | const previousStateRef = useRef(isDraftHeroState); |
| ... | ... | @@ -559,9 +562,6 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { |
| 559 | 562 | const transitionGroup = transitionGroupRef.current; |
| 560 | 563 | const nextComposerRect = composerAnchorRef.current?.getBoundingClientRect() ?? null; |
| 561 | 564 | const stateChanged = previousStateRef.current !== isDraftHeroState; |
| 562 | const prefersReducedMotion = | |
| 563 | typeof window !== "undefined" && | |
| 564 | window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; | |
| 565 | 565 | const mobileComposerTransitionActive = |
| 566 | 566 | typeof document !== "undefined" && |
| 567 | 567 | document.documentElement.dataset.mobileComposerRouteTransition === "true"; |
| ... | ... | @@ -572,7 +572,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { |
| 572 | 572 | const previousComposerRect = previousComposerRectRef.current; |
| 573 | 573 | if ( |
| 574 | 574 | stateChanged && |
| 575 | !prefersReducedMotion && | |
| 575 | animationsActive && | |
| 576 | 576 | !mobileComposerTransitionActive && |
| 577 | 577 | transitionGroup && |
| 578 | 578 | previousComposerRect && |
| ... | ... | @@ -588,7 +588,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { |
| 588 | 588 | { transform: "translate3d(0, 0, 0)" }, |
| 589 | 589 | ], |
| 590 | 590 | { |
| 591 | duration: DRAFT_HERO_TRANSITION_DURATION_MS, | |
| 591 | duration: animationDurationMs, | |
| 592 | 592 | easing: DRAFT_HERO_TRANSITION_EASING, |
| 593 | 593 | }, |
| 594 | 594 | ); |
| ... | ... | @@ -607,7 +607,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) { |
| 607 | 607 | |
| 608 | 608 | previousStateRef.current = isDraftHeroState; |
| 609 | 609 | previousComposerRectRef.current = nextComposerRect; |
| 610 | }, [isDraftHeroState]); | |
| 610 | }, [animationDurationMs, animationsActive, isDraftHeroState]); | |
| 611 | 611 | |
| 612 | 612 | return [attachTransitionGroupRef, attachComposerAnchorRef, captureComposerRect] as const; |
| 613 | 613 | } |
| ... | ... | @@ -3634,7 +3634,11 @@ export default function ChatView(props: ChatViewProps) { |
| 3634 | 3634 | attachDraftHeroTransitionGroupRef, |
| 3635 | 3635 | attachDraftHeroComposerAnchorRef, |
| 3636 | 3636 | captureDraftHeroComposerRect, |
| 3637 | ] = useDraftHeroLayoutTransition(isDraftHeroState); | |
| 3637 | ] = useDraftHeroLayoutTransition( | |
| 3638 | isDraftHeroState, | |
| 3639 | panelAnimationsActive, | |
| 3640 | panelAnimationDurationMs, | |
| 3641 | ); | |
| 3638 | 3642 | |
| 3639 | 3643 | const gitCwd = activeProject |
| 3640 | 3644 | ? projectScriptCwd({ |
| ... | ... | @@ -7877,13 +7881,19 @@ export default function ChatView(props: ChatViewProps) { |
| 7877 | 7881 | const dockStarted = new Promise<void>((resolve) => { |
| 7878 | 7882 | resolveDockStarted = resolve; |
| 7879 | 7883 | }); |
| 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 | ); | |
| 7887 | 7897 | void dockTransition.catch(() => resolveDockStarted?.()); |
| 7888 | 7898 | await dockStarted; |
| 7889 | 7899 | } |
apps/web/src/components/chat/ChatComposer.tsx+14-8| ... | ... | @@ -395,7 +395,6 @@ const EMPTY_PULL_REQUEST_LIST_TARGETS: ReadonlyArray<EnvironmentQueryTarget<Pull |
| 395 | 395 | |
| 396 | 396 | const COMPOSER_SCROLL_COLLAPSE_THRESHOLD_PX = 24; |
| 397 | 397 | const COMPOSER_SCROLL_GESTURE_RESET_MS = 120; |
| 398 | const COMPOSER_RESTING_TRANSITION_DURATION_MS = 280; | |
| 399 | 398 | const COMPOSER_RESTING_TRANSITION_CLEANUP_BUFFER_MS = 50; |
| 400 | 399 | const COMPOSER_RESTING_TRANSITION_EASING = "cubic-bezier(0.32, 0.72, 0, 1)"; |
| 401 | 400 | const COMPOSER_RESTING_CONTROLS_ARRIVAL_DRIFT_PX = 4; |
| ... | ... | @@ -405,6 +404,8 @@ function useComposerRestingTransition( |
| 405 | 404 | isResting: boolean, |
| 406 | 405 | restingControlsRef: React.RefObject<HTMLDivElement | null>, |
| 407 | 406 | onOverlayHeightChange: (height: number) => void, |
| 407 | animationsActive: boolean, | |
| 408 | animationDurationMs: number, | |
| 408 | 409 | ) { |
| 409 | 410 | const elementRef = useRef<HTMLDivElement>(null); |
| 410 | 411 | const isCollapsedRef = useRef(isCollapsed); |
| ... | ... | @@ -523,7 +524,6 @@ function useComposerRestingTransition( |
| 523 | 524 | const previousHeight = interruptedHeight ?? previousHeightRef.current; |
| 524 | 525 | const targetChanged = |
| 525 | 526 | interruptedTargetHeight === null || Math.abs(interruptedTargetHeight - nextHeight) >= 0.5; |
| 526 | const prefersReducedMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; | |
| 527 | 527 | const shouldAnimate = shouldAnimateComposerRestingTransition({ |
| 528 | 528 | hasCompletedInitialLayout: hasCompletedInitialLayoutRef.current, |
| 529 | 529 | stateChanged, |
| ... | ... | @@ -532,18 +532,16 @@ function useComposerRestingTransition( |
| 532 | 532 | |
| 533 | 533 | if ( |
| 534 | 534 | shouldAnimate && |
| 535 | !prefersReducedMotion && | |
| 535 | animationsActive && | |
| 536 | 536 | previousHeight !== null && |
| 537 | 537 | Math.abs(previousHeight - nextHeight) >= 0.5 |
| 538 | 538 | ) { |
| 539 | 539 | const remainingDuration = |
| 540 | 540 | typeof interruptedDuration === "number" && interruptedCurrentTime !== null |
| 541 | 541 | ? Math.max(1, interruptedDuration - interruptedCurrentTime) |
| 542 | : COMPOSER_RESTING_TRANSITION_DURATION_MS; | |
| 542 | : animationDurationMs; | |
| 543 | 543 | const duration = |
| 544 | interruptedHeight !== null && !targetChanged | |
| 545 | ? remainingDuration | |
| 546 | : COMPOSER_RESTING_TRANSITION_DURATION_MS; | |
| 544 | interruptedHeight !== null && !targetChanged ? remainingDuration : animationDurationMs; | |
| 547 | 545 | element.style.overflow = "clip"; |
| 548 | 546 | surface.style.height = "100%"; |
| 549 | 547 | |
| ... | ... | @@ -743,7 +741,13 @@ function useComposerRestingTransition( |
| 743 | 741 | actionFromBottom: nextActionTop === null ? null : nextRect.bottom - nextActionTop, |
| 744 | 742 | }; |
| 745 | 743 | }, |
| 746 | [clearTransitionStyles, onOverlayHeightChange, restingControlsRef], | |
| 744 | [ | |
| 745 | animationDurationMs, | |
| 746 | animationsActive, | |
| 747 | clearTransitionStyles, | |
| 748 | onOverlayHeightChange, | |
| 749 | restingControlsRef, | |
| 750 | ], | |
| 747 | 751 | ); |
| 748 | 752 | |
| 749 | 753 | useLayoutEffect(() => { |
| ... | ... | @@ -4826,6 +4830,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) |
| 4826 | 4830 | isComposerResting, |
| 4827 | 4831 | restingComposerControlsRef, |
| 4828 | 4832 | onComposerOverlayHeightChange, |
| 4833 | panelAnimationsActive, | |
| 4834 | panelAnimationDurationMs, | |
| 4829 | 4835 | ); |
| 4830 | 4836 | const canTrackComposerScrollGesture = |
| 4831 | 4837 | routeKind === "server" && activeThreadId !== null && !isMobileViewport; |
apps/web/src/components/chat/draftHeroTransition.test.ts+32-5| ... | ... | @@ -56,8 +56,9 @@ describe("runMobileComposerTransition", () => { |
| 56 | 56 | finishTransition = resolve; |
| 57 | 57 | }); |
| 58 | 58 | const dataset: Record<string, string> = {}; |
| 59 | const style = { setProperty: vi.fn(), removeProperty: vi.fn() }; | |
| 59 | 60 | vi.stubGlobal("document", { |
| 60 | documentElement: { dataset }, | |
| 61 | documentElement: { dataset, style }, | |
| 61 | 62 | getAnimations: () => [], |
| 62 | 63 | startViewTransition: (update: () => void | Promise<void>) => { |
| 63 | 64 | void update(); |
| ... | ... | @@ -68,7 +69,10 @@ describe("runMobileComposerTransition", () => { |
| 68 | 69 | matchMedia: (query: string) => ({ matches: query === "(max-width: 639px)" }), |
| 69 | 70 | }); |
| 70 | 71 | |
| 71 | const transition = runMobileComposerTransition(() => undefined); | |
| 72 | const transition = runMobileComposerTransition(() => undefined, { | |
| 73 | active: true, | |
| 74 | durationMs: 240, | |
| 75 | }); | |
| 72 | 76 | await Promise.resolve(); |
| 73 | 77 | |
| 74 | 78 | let handoffComplete = false; |
| ... | ... | @@ -85,11 +89,12 @@ describe("runMobileComposerTransition", () => { |
| 85 | 89 | |
| 86 | 90 | it("uses a scoped view transition on mobile", async () => { |
| 87 | 91 | const dataset: Record<string, string> = {}; |
| 92 | const style = { setProperty: vi.fn(), removeProperty: vi.fn() }; | |
| 88 | 93 | const startViewTransition = vi.fn((update: () => void | Promise<void>) => ({ |
| 89 | 94 | finished: Promise.resolve(update()).then(() => undefined), |
| 90 | 95 | })); |
| 91 | 96 | vi.stubGlobal("document", { |
| 92 | documentElement: { dataset }, | |
| 97 | documentElement: { dataset, style }, | |
| 93 | 98 | startViewTransition, |
| 94 | 99 | }); |
| 95 | 100 | vi.stubGlobal("window", { |
| ... | ... | @@ -97,13 +102,35 @@ describe("runMobileComposerTransition", () => { |
| 97 | 102 | }); |
| 98 | 103 | const update = vi.fn(); |
| 99 | 104 | |
| 100 | await runMobileComposerTransition(update); | |
| 105 | await runMobileComposerTransition(update, { active: true, durationMs: 360 }); | |
| 101 | 106 | |
| 102 | 107 | expect(startViewTransition).toHaveBeenCalledOnce(); |
| 103 | 108 | 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"); | |
| 104 | 114 | expect(dataset).not.toHaveProperty("mobileComposerRouteTransition"); |
| 105 | 115 | }); |
| 106 | 116 | |
| 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 | ||
| 107 | 134 | it("updates without a view transition when reduced motion is preferred", async () => { |
| 108 | 135 | const startViewTransition = vi.fn(); |
| 109 | 136 | vi.stubGlobal("document", { |
| ... | ... | @@ -115,7 +142,7 @@ describe("runMobileComposerTransition", () => { |
| 115 | 142 | }); |
| 116 | 143 | const update = vi.fn(); |
| 117 | 144 | |
| 118 | await runMobileComposerTransition(update); | |
| 145 | await runMobileComposerTransition(update, { active: true, durationMs: 360 }); | |
| 119 | 146 | |
| 120 | 147 | expect(startViewTransition).not.toHaveBeenCalled(); |
| 121 | 148 | expect(update).toHaveBeenCalledOnce(); |
apps/web/src/components/chat/draftHeroTransition.ts+15-2| ... | ... | @@ -1,8 +1,8 @@ |
| 1 | 1 | export const DRAFT_HERO_TRANSITION_ANIMATION_ID = "t3-draft-hero-transition"; |
| 2 | export const DRAFT_HERO_TRANSITION_DURATION_MS = 180; | |
| 3 | 2 | export const DRAFT_HERO_TRANSITION_EASING = "cubic-bezier(0.4, 0, 0.2, 1)"; |
| 4 | 3 | export const MOBILE_COMPOSER_VIEW_TRANSITION_NAME = "t3-mobile-composer"; |
| 5 | 4 | export const MOBILE_DRAFT_HEADLINE_VIEW_TRANSITION_NAME = "t3-mobile-draft-headline"; |
| 5 | const MOBILE_COMPOSER_TRANSITION_DURATION_PROPERTY = "--mobile-composer-transition-duration"; | |
| 6 | 6 | |
| 7 | 7 | type ComposerViewTransition = { |
| 8 | 8 | readonly finished: Promise<void>; |
| ... | ... | @@ -39,6 +39,7 @@ export async function waitForDraftHeroTransition(): Promise<void> { |
| 39 | 39 | |
| 40 | 40 | export async function runMobileComposerTransition( |
| 41 | 41 | update: () => void | Promise<void>, |
| 42 | options: { active: boolean; durationMs: number }, | |
| 42 | 43 | ): Promise<void> { |
| 43 | 44 | if (typeof document === "undefined" || typeof window === "undefined") { |
| 44 | 45 | await update(); |
| ... | ... | @@ -49,7 +50,12 @@ export async function runMobileComposerTransition( |
| 49 | 50 | const mobileViewport = window.matchMedia?.("(max-width: 639px)").matches ?? false; |
| 50 | 51 | const prefersReducedMotion = |
| 51 | 52 | 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 | ) { | |
| 53 | 59 | await update(); |
| 54 | 60 | return; |
| 55 | 61 | } |
| ... | ... | @@ -61,6 +67,10 @@ export async function runMobileComposerTransition( |
| 61 | 67 | await update(); |
| 62 | 68 | }; |
| 63 | 69 | let transitionFinished: Promise<void> | null = null; |
| 70 | transitionDocument.documentElement.style.setProperty( | |
| 71 | MOBILE_COMPOSER_TRANSITION_DURATION_PROPERTY, | |
| 72 | `${String(options.durationMs)}ms`, | |
| 73 | ); | |
| 64 | 74 | transitionDocument.documentElement.dataset.mobileComposerRouteTransition = "true"; |
| 65 | 75 | try { |
| 66 | 76 | const transition = transitionDocument.startViewTransition(runUpdate); |
| ... | ... | @@ -78,5 +88,8 @@ export async function runMobileComposerTransition( |
| 78 | 88 | activeMobileComposerTransition = null; |
| 79 | 89 | } |
| 80 | 90 | delete transitionDocument.documentElement.dataset.mobileComposerRouteTransition; |
| 91 | transitionDocument.documentElement.style.removeProperty( | |
| 92 | MOBILE_COMPOSER_TRANSITION_DURATION_PROPERTY, | |
| 93 | ); | |
| 81 | 94 | } |
| 82 | 95 | } |
apps/web/src/index.css+6-5| ... | ... | @@ -13,7 +13,7 @@ html[data-mobile-composer-route-transition="true"]::view-transition-new(root) { |
| 13 | 13 | } |
| 14 | 14 | |
| 15 | 15 | html[data-mobile-composer-route-transition="true"]::view-transition-group(t3-mobile-composer) { |
| 16 | animation-duration: 180ms; | |
| 16 | animation-duration: var(--mobile-composer-transition-duration); | |
| 17 | 17 | animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1); |
| 18 | 18 | } |
| 19 | 19 | |
| ... | ... | @@ -22,12 +22,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-image-pair(t |
| 22 | 22 | } |
| 23 | 23 | |
| 24 | 24 | html[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; | |
| 26 | 26 | mix-blend-mode: normal; |
| 27 | 27 | } |
| 28 | 28 | |
| 29 | 29 | html[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; | |
| 31 | 31 | mix-blend-mode: normal; |
| 32 | 32 | } |
| 33 | 33 | |
| ... | ... | @@ -57,11 +57,12 @@ html[data-mobile-composer-route-transition="true"]::view-transition-new(t3-mobil |
| 57 | 57 | } |
| 58 | 58 | |
| 59 | 59 | html[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); | |
| 61 | 61 | } |
| 62 | 62 | |
| 63 | 63 | html[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; | |
| 65 | 66 | mix-blend-mode: normal; |
| 66 | 67 | } |
| 67 | 68 |