Skip to content
Closed
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
21 changes: 15 additions & 6 deletions apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
useState,
} from "react";
import {
AccessibilityInfo,
Alert,
AppState,
Keyboard,
Expand Down Expand Up @@ -737,14 +738,15 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread
// leaves the keyboard padding permanently applied — overshooting the
// anchor and leaving a phantom bottom inset once the reply streams in.
void KeyboardController.dismiss()
.then(() => {
.then(() => AccessibilityInfo.isReduceMotionEnabled().catch(() => true))
.then((reducedMotion) => {
if (
selectedThreadKeyRef.current !== targetThreadKey ||
lastScrolledSubmittedMessageIdRef.current !== submittedMessageId
) {
return;
}
return scrollMessageToEnd({ animated: true, closeKeyboard: false });
return scrollMessageToEnd({ animated: !reducedMotion, closeKeyboard: false });
})
.catch(() => {
if (
Expand Down Expand Up @@ -842,10 +844,17 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread

const handleScrollToEnd = useCallback(() => {
void Haptics.selectionAsync();
void scrollMessageToEnd({ animated: true, closeKeyboard: false }).catch(() => {
freeze.set(false);
});
}, [freeze, scrollMessageToEnd]);
const targetThreadKey = selectedThreadKey;
void AccessibilityInfo.isReduceMotionEnabled()
.catch(() => true)
.then((reducedMotion) => {
if (selectedThreadKeyRef.current !== targetThreadKey) return;
return scrollMessageToEnd({ animated: !reducedMotion, closeKeyboard: false });
})
.catch(() => {
if (selectedThreadKeyRef.current === targetThreadKey) freeze.set(false);
});
}, [freeze, scrollMessageToEnd, selectedThreadKey]);

const showScrollToEndButton = contentPresentationKind === "ready" && !endFollowEnabled;
const { themeAppearance } = useAppearancePreferences();
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,7 @@ import {
} from "../state/entities";
import { environmentShell } from "../state/shell";
import { ChatComposer, type ChatComposerHandle } from "./chat/ChatComposer";
import { prefersReducedMotion as readReducedMotion } from "../lib/reducedMotion";
import { createPageScrollController, type PageScrollKey } from "./chat/pageScrollController";
import { isTimelineScrollTarget } from "./chat/timelineScrollTarget";
import { DraftHeroHeadline } from "./chat/DraftHeroHeadline";
Expand Down Expand Up @@ -5406,7 +5407,7 @@ export default function ChatView(props: ChatViewProps) {
setShowScrollToBottom(false);
setTimelineAnchor(releaseChatTimelineAnchor);
requestAnimationFrame(() => {
void legendListRef.current?.scrollToEnd?.({ animated });
void legendListRef.current?.scrollToEnd?.({ animated: animated && !readReducedMotion() });
});
}, []);
useLayoutEffect(() => {
Expand Down Expand Up @@ -5619,7 +5620,7 @@ export default function ChatView(props: ChatViewProps) {
void list
.scrollToIndex({
index: anchorIndex,
animated: true,
animated: !readReducedMotion(),
viewPosition: 0,
viewOffset: CHAT_TIMELINE_ANCHOR_OFFSET,
})
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/RightPanelTabs.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { prefersReducedMotion as readReducedMotion } from "../lib/reducedMotion";
import { pullRequestHostOf, type SourceControlProviderKind } from "@t3tools/contracts";
import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import { useProjects, useServerConfigs, useThreadShells } from "~/state/entities";
Expand Down Expand Up @@ -861,7 +862,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) {
const scrollTabs = useCallback((direction: -1 | 1) => {
const viewport = tabScrollViewport(tabListRef.current);
if (!viewport) return;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const reduceMotion = readReducedMotion();
viewport.scrollBy({
left: direction * Math.max(120, viewport.clientWidth * 0.75),
behavior: reduceMotion ? "auto" : "smooth",
Expand Down
39 changes: 25 additions & 14 deletions apps/web/src/components/chat/AssistantCitationSource.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,20 +440,31 @@ describe("assistant citation source lifecycle", () => {
source.cleanup();
});

it("uses instant navigation and removes the temporary quote highlight for reduced motion", async () => {
const source = createSource({ reducedMotion: true });
source.mount();
source.flushFrame();
expect(source.scrollToOffset).toHaveBeenCalledWith({ offset: 80, animated: false });
expect(source.highlight()).toBeUndefined();
await source.finishScroll();
expect(source.target.activationRef.current.pulse?.reducedMotion).toBe(true);
expect(source.highlight()?.toString()).toBe("quote");
await source.finishPulse();
expect(source.highlight()).toBeUndefined();
expect(source.root.focus).not.toHaveBeenCalled();
source.cleanup();
});
it.each(["reduced motion", "missing matchMedia"])(
"uses instant navigation and preserves selection and acknowledgement with %s",
async (preference) => {
const source = createSource({ reducedMotion: true });
if (preference === "missing matchMedia") vi.stubGlobal("window", {});
const selection = new TestSelection();
const selectedRange = new TestRange(source.text, () => rect(0));
selection.addRange(selectedRange);
source.root.ownerDocument.getSelection = () => selection;
source.mount();
source.flushFrame();
expect(source.scrollToOffset).toHaveBeenCalledWith({ offset: 80, animated: false });
expect(source.highlight()).toBeUndefined();
await source.finishScroll();
expect(source.scrollNode.scrollTop).toBe(80);
expect(source.target.onComplete).toHaveBeenCalledOnce();
expect(selection.ranges).toEqual([selectedRange]);
expect(source.target.activationRef.current.pulse?.reducedMotion).toBe(true);
expect(source.highlight()?.toString()).toBe("quote");
await source.finishPulse();
expect(source.highlight()).toBeUndefined();
expect(source.root.focus).not.toHaveBeenCalled();
source.cleanup();
},
);

it("does not revive a quote when remounting after its original pulse deadline", async () => {
const source = createSource();
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/chat/AssistantCitationSource.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { prefersReducedMotion as readReducedMotion } from "../../lib/reducedMotion";
import type { LegendListRef } from "@legendapp/list/react";
import type { AssistantCitation, MessageId, ScopedThreadRef } from "@t3tools/contracts";
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
Expand Down Expand Up @@ -117,7 +118,7 @@ export function observeAssistantCitationSource({
if (activation.dismissed) return;
const scrollNode = list.getScrollableNode();
if (!(scrollNode instanceof HTMLElement)) return;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const reducedMotion = readReducedMotion();
let highlighted: Highlight | null = null;
let ownedRange: Range | null = null;
let selected: { range: Range; snapshot: Range } | null = null;
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/chat/MessagesTimeline.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { prefersReducedMotion as readReducedMotion } from "../../lib/reducedMotion";
import { ArrowUpIcon, ClockIcon } from "lucide-react";
import { ReadOnlySourcePreview } from "../files/AttachmentFilePreview";
import { useRightPanelStore } from "~/rightPanelStore";
Expand Down Expand Up @@ -1354,7 +1355,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({
onManualNavigation();
void listRef.current?.scrollToIndex({
index: item.rowIndex,
animated: true,
animated: !readReducedMotion(),
viewOffset: 24,
});
}}
Expand Down
134 changes: 106 additions & 28 deletions apps/web/src/components/chat/pageScrollController.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, test } from "vite-plus/test";
import { afterEach, describe, expect, test, vi } from "vite-plus/test";

import {
createPageScrollController,
Expand Down Expand Up @@ -80,6 +80,78 @@ class TestClock {
}
}

afterEach(() => vi.unstubAllGlobals());

describe("page scroll motion preference", () => {
test.each(["missing window", "missing matchMedia"])(
"reaches the same destination and acknowledges the action with %s",
(unavailable) => {
const clock = new TestClock();
const container = {
clientHeight: 600,
scrollHeight: 4_000,
scrollTop: 0,
getBoundingClientRect: () => ({ height: 600 }),
};
vi.stubGlobal("window", { matchMedia: () => ({ matches: false }) });
const started: string[] = [];
const controller = createPageScrollController({
getContainer: () => container,
getScrollPaddingBottomPx: () => 24,
onScrollStart: (key) => started.push(key),
env: clock.env,
});
controller.handleKeyDown("PageDown");
controller.handleKeyUp("PageDown");
expect(container.scrollTop).toBe(0);
clock.advanceBy(PAGE_SCROLL_ANIMATION_MS);
const destination = container.scrollTop;
expect(destination).toBeGreaterThan(0);

container.scrollTop = 0;
vi.stubGlobal("window", unavailable === "missing window" ? undefined : {});
controller.handleKeyDown("PageDown");
controller.handleKeyUp("PageDown");
expect(container.scrollTop).toBe(destination);
expect(started).toEqual(["PageDown", "PageDown"]);
clock.advanceBy(PAGE_SCROLL_ANIMATION_MS);
expect(container.scrollTop).toBe(destination);
controller.dispose();
},
);

test("reaches the same page immediately after the preference changes", () => {
const clock = new TestClock();
const container = {
clientHeight: 600,
scrollHeight: 4_000,
scrollTop: 0,
getBoundingClientRect: () => ({ height: 600 }),
};
let reducedMotion = false;
const controller = createPageScrollController({
getContainer: () => container,
getScrollPaddingBottomPx: () => 24,
isReducedMotion: () => reducedMotion,
env: clock.env,
});
controller.handleKeyDown("PageDown");
controller.handleKeyUp("PageDown");
expect(container.scrollTop).toBe(0);
clock.advanceBy(PAGE_SCROLL_ANIMATION_MS);
const destination = container.scrollTop;
expect(destination).toBeGreaterThan(0);
container.scrollTop = 0;
reducedMotion = true;
controller.handleKeyDown("PageDown");
controller.handleKeyUp("PageDown");
expect(container.scrollTop).toBe(destination);
clock.advanceBy(PAGE_SCROLL_ANIMATION_MS);
expect(container.scrollTop).toBe(destination);
controller.dispose();
});
});

describe("page scroll helpers", () => {
const composerPageScrollEvent = (
overrides: Partial<Parameters<typeof getTimelinePageScrollKey>[0]> = {},
Expand Down Expand Up @@ -185,11 +257,13 @@ describe("createPageScrollController", () => {
const controller = createPageScrollController({
getContainer: () => container,
getScrollPaddingBottomPx: () => 24,
isReducedMotion: () => false,
env: clock.env,
});

controller.handleKeyDown("PageDown");
controller.handleKeyUp("PageDown");
expect(container.scrollTop).toBe(0);
clock.advanceBy(PAGE_SCROLL_ANIMATION_MS);

expect(container.scrollTop).toBeCloseTo(
Expand All @@ -201,33 +275,37 @@ describe("createPageScrollController", () => {
);
});

test("continues scrolling on hold without repeated keydown events and stops on keyup", () => {
const clock = new TestClock();
const container = {
clientHeight: 600,
scrollHeight: 4_000,
scrollTop: 0,
getBoundingClientRect: () => ({ height: 600 }),
};
const controller = createPageScrollController({
getContainer: () => container,
getScrollPaddingBottomPx: () => 24,
env: clock.env,
});
controller.handleKeyDown("PageDown");
clock.advanceBy(PAGE_SCROLL_ANIMATION_MS + 50);

const afterHoldStarts = container.scrollTop;
clock.advanceBy(200);

expect(container.scrollTop).toBeGreaterThan(afterHoldStarts);

const stoppedAt = container.scrollTop;
controller.handleKeyUp("PageDown");
clock.advanceBy(250);

expect(container.scrollTop).toBe(stoppedAt);
});
test.each([false, true])(
"continues held scrolling and stops on keyup (reduced motion: %s)",
(reducedMotion) => {
const clock = new TestClock();
const container = {
clientHeight: 600,
scrollHeight: 4_000,
scrollTop: 0,
getBoundingClientRect: () => ({ height: 600 }),
};
const controller = createPageScrollController({
getContainer: () => container,
getScrollPaddingBottomPx: () => 24,
isReducedMotion: () => reducedMotion,
env: clock.env,
});
controller.handleKeyDown("PageDown");
clock.advanceBy(PAGE_SCROLL_ANIMATION_MS + 50);

const afterHoldStarts = container.scrollTop;
clock.advanceBy(200);

expect(container.scrollTop).toBeGreaterThan(afterHoldStarts);

const stoppedAt = container.scrollTop;
controller.handleKeyUp("PageDown");
clock.advanceBy(250);

expect(container.scrollTop).toBe(stoppedAt);
},
);

test("notifies once when a page scroll starts", () => {
const clock = new TestClock();
Expand Down
9 changes: 9 additions & 0 deletions apps/web/src/components/chat/pageScrollController.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { prefersReducedMotion } from "../../lib/reducedMotion";

export const PAGE_SCROLL_ANIMATION_MS = 150;
export const PAGE_SCROLL_ACCELERATION_MS = 400;
export const PAGE_SCROLL_MAX_MULTIPLIER = 2;
Expand Down Expand Up @@ -149,11 +151,13 @@ export function createPageScrollController({
getContainer,
getScrollPaddingBottomPx,
onScrollStart,
isReducedMotion = prefersReducedMotion,
env = getDefaultEnv(),
}: {
getContainer: () => PageScrollContainer | null;
getScrollPaddingBottomPx: () => number;
onScrollStart?: (key: PageScrollKey) => void;
isReducedMotion?: () => boolean;
env?: PageScrollEnv;
}) {
const state = {
Expand Down Expand Up @@ -209,6 +213,11 @@ export function createPageScrollController({
const smoothScrollBy = (container: PageScrollContainer, deltaY: number) => {
cancelDiscreteAnimation();

if (isReducedMotion()) {
container.scrollTop += deltaY;
return;
}

const startScrollTop = container.scrollTop;
const startTime = env.now();

Expand Down
Loading
Loading