Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 26 additions & 16 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, PendingUserInputDraftAnswer> = {};
function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
function useDraftHeroLayoutTransition(
isDraftHeroState: boolean,
animationsActive: boolean,
animationDurationMs: number,
) {
const transitionGroupRef = useRef<HTMLDivElement | null>(null);
const composerAnchorRef = useRef<HTMLDivElement | null>(null);
const previousStateRef = useRef(isDraftHeroState);
Expand All @@ -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";
Expand All @@ -523,7 +523,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {
const previousComposerRect = previousComposerRectRef.current;
if (
stateChanged &&
!prefersReducedMotion &&
animationsActive &&
!mobileComposerTransitionActive &&
transitionGroup &&
previousComposerRect &&
Expand All @@ -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,
},
);
Expand All @@ -558,7 +558,7 @@ function useDraftHeroLayoutTransition(isDraftHeroState: boolean) {

previousStateRef.current = isDraftHeroState;
previousComposerRectRef.current = nextComposerRect;
}, [isDraftHeroState]);
}, [animationDurationMs, animationsActive, isDraftHeroState]);

return [attachTransitionGroupRef, attachComposerAnchorRef, captureComposerRect] as const;
}
Expand Down Expand Up @@ -3275,7 +3275,11 @@ export default function ChatView(props: ChatViewProps) {
attachDraftHeroTransitionGroupRef,
attachDraftHeroComposerAnchorRef,
captureDraftHeroComposerRect,
] = useDraftHeroLayoutTransition(isDraftHeroState);
] = useDraftHeroLayoutTransition(
isDraftHeroState,
panelAnimationsActive,
panelAnimationDurationMs,
);

const gitCwd = activeProject
? projectScriptCwd({
Expand Down Expand Up @@ -6996,13 +7000,19 @@ export default function ChatView(props: ChatViewProps) {
const dockStarted = new Promise<void>((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;
}
Expand Down
22 changes: 14 additions & 8 deletions apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -322,6 +321,8 @@ function useComposerRestingTransition(
isResting: boolean,
restingControlsRef: React.RefObject<HTMLDivElement | null>,
onOverlayHeightChange: (height: number) => void,
animationsActive: boolean,
animationDurationMs: number,
) {
const elementRef = useRef<HTMLDivElement>(null);
const isCollapsedRef = useRef(isCollapsed);
Expand Down Expand Up @@ -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,
Expand All @@ -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%";

Expand Down Expand Up @@ -660,7 +658,13 @@ function useComposerRestingTransition(
actionFromBottom: nextActionTop === null ? null : nextRect.bottom - nextActionTop,
};
},
[clearTransitionStyles, onOverlayHeightChange, restingControlsRef],
[
animationDurationMs,
animationsActive,
clearTransitionStyles,
onOverlayHeightChange,
restingControlsRef,
],
);

useLayoutEffect(() => {
Expand Down Expand Up @@ -3946,6 +3950,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
isComposerResting,
restingComposerControlsRef,
onComposerOverlayHeightChange,
panelAnimationsActive,
panelAnimationDurationMs,
);
const canTrackComposerScrollGesture =
routeKind === "server" && activeThreadId !== null && !isMobileViewport;
Expand Down
37 changes: 32 additions & 5 deletions apps/web/src/components/chat/draftHeroTransition.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ describe("runMobileComposerTransition", () => {
finishTransition = resolve;
});
const dataset: Record<string, string> = {};
const style = { setProperty: vi.fn(), removeProperty: vi.fn() };
vi.stubGlobal("document", {
documentElement: { dataset },
documentElement: { dataset, style },
getAnimations: () => [],
startViewTransition: (update: () => void | Promise<void>) => {
void update();
Expand All @@ -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;
Expand All @@ -85,25 +89,48 @@ describe("runMobileComposerTransition", () => {

it("uses a scoped view transition on mobile", async () => {
const dataset: Record<string, string> = {};
const style = { setProperty: vi.fn(), removeProperty: vi.fn() };
const startViewTransition = vi.fn((update: () => void | Promise<void>) => ({
finished: Promise.resolve(update()).then(() => undefined),
}));
vi.stubGlobal("document", {
documentElement: { dataset },
documentElement: { dataset, style },
startViewTransition,
});
vi.stubGlobal("window", {
matchMedia: (query: string) => ({ matches: query === "(max-width: 639px)" }),
});
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", {
Expand All @@ -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();
Expand Down
17 changes: 15 additions & 2 deletions apps/web/src/components/chat/draftHeroTransition.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
Expand Down Expand Up @@ -39,6 +39,7 @@ export async function waitForDraftHeroTransition(): Promise<void> {

export async function runMobileComposerTransition(
update: () => void | Promise<void>,
options: { active: boolean; durationMs: number },
): Promise<void> {
if (typeof document === "undefined" || typeof window === "undefined") {
await update();
Expand All @@ -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;
}
Expand All @@ -61,6 +67,10 @@ export async function runMobileComposerTransition(
await update();
};
let transitionFinished: Promise<void> | 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);
Expand All @@ -78,5 +88,8 @@ export async function runMobileComposerTransition(
activeMobileComposerTransition = null;
}
delete transitionDocument.documentElement.dataset.mobileComposerRouteTransition;
transitionDocument.documentElement.style.removeProperty(
MOBILE_COMPOSER_TRANSITION_DURATION_PROPERTY,
);
}
}
11 changes: 6 additions & 5 deletions apps/web/src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down
Loading