diff --git a/packages/app/src/pages/session/session-timeline-header.tsx b/packages/app/src/pages/session/session-timeline-header.tsx index c709b64154..fcddb38a47 100644 --- a/packages/app/src/pages/session/session-timeline-header.tsx +++ b/packages/app/src/pages/session/session-timeline-header.tsx @@ -5,6 +5,7 @@ import { Button } from "@opencode-ai/ui/button" import { IconButton } from "@opencode-ai/ui/icon-button" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Dialog } from "@opencode-ai/ui/dialog" +import { prefersReducedMotion } from "@opencode-ai/ui/hooks" import { InlineInput } from "@opencode-ai/ui/inline-input" import { animate, type AnimationPlaybackControls, clearFadeStyles, FAST_SPRING } from "@opencode-ai/ui/motion" import { showToast } from "@opencode-ai/ui/toast" @@ -31,6 +32,7 @@ export function SessionTimelineHeader(props: { const sync = useSync() const dialog = useDialog() const language = useLanguage() + const reduce = prefersReducedMotion const [title, setTitle] = createStore({ draft: "", @@ -64,7 +66,7 @@ export function SessionTimelineHeader(props: { if (!el) return clearHeaderAnim() - if (!headerText.muted) { + if (!headerText.muted || reduce()) { el.style.opacity = "1" return } @@ -96,6 +98,10 @@ export function SessionTimelineHeader(props: { const animateEnterSpan = () => { if (!enterRef) return + if (reduce()) { + settleTitleEnter() + return + } enterAnim = animate( enterRef, { opacity: [0, 1], filter: ["blur(2px)", "blur(0px)"], transform: ["translateY(-2px)", "translateY(0)"] }, @@ -109,6 +115,13 @@ export function SessionTimelineHeader(props: { setHeaderText({ prev: headerText.value, prevMuted: headerText.muted }) setHeaderText({ value: nextTitle, muted: nextMuted }) + if (reduce()) { + setHeaderText({ prev: undefined, prevMuted: false }) + hideLeave() + settleTitleEnter() + return + } + if (leaveRef) { leaveAnim = animate( leaveRef, diff --git a/packages/ui/src/components/grow-box.tsx b/packages/ui/src/components/grow-box.tsx index b78883c522..ec4921ab3a 100644 --- a/packages/ui/src/components/grow-box.tsx +++ b/packages/ui/src/components/grow-box.tsx @@ -1,5 +1,6 @@ import { createEffect, on, type JSX, onMount, onCleanup } from "solid-js" import { animate, tunableSpringValue, type AnimationPlaybackControls, GROW_SPRING, type SpringConfig } from "./motion" +import { prefersReducedMotion } from "../hooks/use-reduced-motion" export interface GrowBoxProps { children: JSX.Element @@ -48,6 +49,7 @@ export interface GrowBoxProps { * Used for timeline turns, assistant part groups, and user messages. */ export function GrowBox(props: GrowBoxProps) { + const reduce = prefersReducedMotion const spring = () => props.spring ?? GROW_SPRING const toggleSpring = () => props.toggleSpring ?? spring() let mode: "mount" | "toggle" = "mount" @@ -83,7 +85,8 @@ export function GrowBox(props: GrowBoxProps) { const edgeIdle = () => Math.max(0, props.edgeIdle ?? 320) const edgeFade = () => Math.max(0.05, props.edgeFade ?? 0.24) const edgeRise = () => Math.max(0.05, props.edgeRise ?? 0.2) - const edgeReady = () => props.animate !== false && open() && edge() && edgeHeight() > 0 + const animated = () => props.animate !== false && !reduce() + const edgeReady = () => animated() && open() && edge() && edgeHeight() > 0 const stopEdgeTimer = () => { if (edgeTimer === undefined) return @@ -99,7 +102,7 @@ export function GrowBox(props: GrowBoxProps) { } edgeAnim?.stop() edgeAnim = undefined - if (instant) { + if (instant || reduce()) { edgeRef.style.opacity = "0" edgeOn = false return @@ -124,6 +127,11 @@ export function GrowBox(props: GrowBoxProps) { const showEdge = () => { stopEdgeTimer() if (!edgeRef) return + if (reduce()) { + edgeRef.style.opacity = `${edgeOpacity()}` + edgeOn = true + return + } if (edgeOn && edgeAnim === undefined) { edgeRef.style.opacity = `${edgeOpacity()}` return @@ -175,6 +183,10 @@ export function GrowBox(props: GrowBoxProps) { const fadeBodyIn = (nextMode: "mount" | "toggle" = "mount") => { if (props.fade === false || !body) return + if (reduce()) { + clearBody() + return + } hideBody() fadeAnim?.stop() fadeAnim = animate(body, { opacity: 1, filter: "blur(0px)" }, nextMode === "toggle" ? toggleSpring() : spring()) @@ -185,6 +197,9 @@ export function GrowBox(props: GrowBoxProps) { } const setInstant = (visible: boolean) => { + const next = visible ? targetHeight() : 0 + springTarget = next + height.jump(next) root!.style.height = visible ? "" : "0px" root!.style.overflow = visible ? "" : "clip" hideEdge(true) @@ -207,6 +222,18 @@ export function GrowBox(props: GrowBoxProps) { const setHeight = (nextMode: "mount" | "toggle" = "mount") => { if (!root || !open()) return const next = targetHeight() + if (reduce()) { + springTarget = next + height.jump(next) + if (props.autoHeight === false || watch()) { + root.style.height = `${next}px` + root.style.overflow = next > 0 ? "visible" : "clip" + return + } + root.style.height = "auto" + root.style.overflow = next > 0 ? "visible" : "clip" + return + } if (next === springTarget) return const prev = currentHeight() if (Math.abs(next - prev) < 1) { @@ -266,7 +293,7 @@ export function GrowBox(props: GrowBoxProps) { offChange() }) - if (!props.animate) { + if (!animated()) { setInstant(open()) return } @@ -310,7 +337,7 @@ export function GrowBox(props: GrowBoxProps) { (value) => { if (value === undefined) return if (!root || !body) return - if (!animateToggle()) { + if (!animateToggle() || reduce()) { setInstant(value) return } @@ -342,7 +369,7 @@ export function GrowBox(props: GrowBoxProps) { createEffect(() => { if (!edgeRef) return edgeRef.style.height = `${edgeHeight()}px` - if (props.animate === false || !open() || edgeHeight() <= 0) { + if (!animated() || !open() || edgeHeight() <= 0) { hideEdge(true) return } @@ -350,6 +377,14 @@ export function GrowBox(props: GrowBoxProps) { hideEdge() }) + createEffect(() => { + if (!root || !body) return + if (!reduce()) return + fadeAnim?.stop() + edgeAnim?.stop() + setInstant(open()) + }) + onCleanup(() => { stopEdgeTimer() if (mountFrame !== undefined) cancelAnimationFrame(mountFrame) diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx index e31554c7d0..09557b3d05 100644 --- a/packages/ui/src/components/message-part.tsx +++ b/packages/ui/src/components/message-part.tsx @@ -895,11 +895,12 @@ function ContextToolRollingResults(props: { parts: ToolPart[]; pending: boolean const wiped = new Set() const [mounted, setMounted] = createSignal(false) onMount(() => setMounted(true)) + const reduce = prefersReducedMotion const show = () => mounted() && props.pending const opacity = useSpring(() => (show() ? 1 : 0), GROW_SPRING) const blur = useSpring(() => (show() ? 0 : 2), GROW_SPRING) return ( -
+
const eq = (a: Opt | undefined, b: Opt | undefined) => @@ -13,24 +14,41 @@ const eq = (a: Opt | undefined, b: Opt | undefined) => export function useSpring(target: () => number, options?: Opt | (() => Opt)) { const read = () => (typeof options === "function" ? options() : options) + const reduce = prefersReducedMotion const [value, setValue] = createSignal(target()) const source = motionValue(value()) const spring = motionValue(value()) let config = read() - let stop = attachSpring(spring, source, config) + let reduced = reduce() + let stop = reduced ? () => {} : attachSpring(spring, source, config) let off = spring.on("change", (next) => setValue(next)) createEffect(() => { - source.set(target()) + const next = target() + if (reduced) { + source.set(next) + spring.set(next) + setValue(next) + return + } + source.set(next) }) createEffect(() => { - if (!options) return const next = read() - if (eq(config, next)) return + const skip = reduce() + if (eq(config, next) && reduced === skip) return config = next + reduced = skip stop() - stop = attachSpring(spring, source, next) + stop = skip ? () => {} : attachSpring(spring, source, next) + if (skip) { + const value = target() + source.set(value) + spring.set(value) + setValue(value) + return + } setValue(spring.get()) })