From 3ab8b826836566975d39587201db52470d5cb47e Mon Sep 17 00:00:00 2001 From: murashit Date: Wed, 24 Jun 2026 10:37:26 +0900 Subject: [PATCH] Smooth manual message stream scrolling --- .../application/composer/boundary-scroll.ts | 22 +++-- .../chat/ui/message-stream/flow-scroll.ts | 70 +++++++++++--- .../composer/boundary-scroll.test.ts | 23 +++++ .../ui/message-stream/flow-scroll.test.ts | 94 ++++++++++++++++++- 4 files changed, 186 insertions(+), 23 deletions(-) diff --git a/src/features/chat/application/composer/boundary-scroll.ts b/src/features/chat/application/composer/boundary-scroll.ts index 70124edb..f3484572 100644 --- a/src/features/chat/application/composer/boundary-scroll.ts +++ b/src/features/chat/application/composer/boundary-scroll.ts @@ -8,6 +8,7 @@ interface ComposerBoundaryScrollByAction { kind: "scroll-by"; direction: ComposerBoundaryScrollDirection; amount: ComposerBoundaryScrollAmount; + repeated?: boolean; } interface ComposerBoundaryScrollToAction { @@ -17,6 +18,7 @@ interface ComposerBoundaryScrollToAction { export interface ComposerBoundaryScrollKeyEvent { key: string; + repeat: boolean; ctrlKey: boolean; metaKey: boolean; altKey: boolean; @@ -57,21 +59,29 @@ export function composerBoundaryScrollDirection( function composerBoundaryScrollKeyAction(event: ComposerBoundaryScrollKeyEvent): ComposerBoundaryScrollAction | null { if (!event.ctrlKey) { - if (event.key === "ArrowUp") return { kind: "scroll-by", direction: -1, amount: "text-lines" }; - if (event.key === "ArrowDown") return { kind: "scroll-by", direction: 1, amount: "text-lines" }; - if (event.key === "PageUp") return { kind: "scroll-by", direction: -1, amount: "page" }; - if (event.key === "PageDown") return { kind: "scroll-by", direction: 1, amount: "page" }; + if (event.key === "ArrowUp") return composerBoundaryScrollByAction(-1, "text-lines", event.repeat); + if (event.key === "ArrowDown") return composerBoundaryScrollByAction(1, "text-lines", event.repeat); + if (event.key === "PageUp") return composerBoundaryScrollByAction(-1, "page", event.repeat); + if (event.key === "PageDown") return composerBoundaryScrollByAction(1, "page", event.repeat); if (event.key === "Home") return { kind: "scroll-to", edge: "start" }; if (event.key === "End") return { kind: "scroll-to", edge: "end" }; return null; } const key = event.key.toLowerCase(); - if (key === "p") return { kind: "scroll-by", direction: -1, amount: "text-lines" }; - if (key === "n") return { kind: "scroll-by", direction: 1, amount: "text-lines" }; + if (key === "p") return composerBoundaryScrollByAction(-1, "text-lines", event.repeat); + if (key === "n") return composerBoundaryScrollByAction(1, "text-lines", event.repeat); return null; } +function composerBoundaryScrollByAction( + direction: ComposerBoundaryScrollDirection, + amount: ComposerBoundaryScrollAmount, + repeat: boolean, +): ComposerBoundaryScrollByAction { + return repeat ? { kind: "scroll-by", direction, amount, repeated: true } : { kind: "scroll-by", direction, amount }; +} + function composerCursorOnFirstLine(composer: ComposerBoundaryScrollTextState): boolean { return !composer.value.slice(0, composer.selectionStart).includes("\n"); } diff --git a/src/features/chat/ui/message-stream/flow-scroll.ts b/src/features/chat/ui/message-stream/flow-scroll.ts index ae4d8b43..4c1a9827 100644 --- a/src/features/chat/ui/message-stream/flow-scroll.ts +++ b/src/features/chat/ui/message-stream/flow-scroll.ts @@ -4,10 +4,13 @@ import { MESSAGE_CONTENT_RENDERED_EVENT } from "./content-events"; type MessageScrollDirection = -1 | 1; +const MESSAGE_FLOW_TEXT_LINE_SCROLL_LINES = 4; +const MESSAGE_FLOW_REPEATED_TEXT_LINE_SCROLL_LINES = 4; + export type MessageStreamScrollCommand = | { kind: "show-latest" } | { kind: "scroll-to"; edge: "start" | "end" } - | { kind: "scroll-by"; amount: "text-lines" | "page"; direction: MessageScrollDirection }; + | { kind: "scroll-by"; amount: "text-lines" | "page"; direction: MessageScrollDirection; repeated?: boolean }; export interface MessageStreamScrollPort { dispatchScrollCommand(command: MessageStreamScrollCommand): void; @@ -210,15 +213,18 @@ function applyMessageFlowScrollCommand(runtime: MessageFlowRuntime, command: Mes break; case "scroll-to": if (command.edge === "start") { - scrollMessageFlowToStart(runtime); + scrollMessageFlowToStart(runtime, messageFlowManualScrollBehavior(runtime, false)); } else { runtime.followingEnd = true; - scrollMessageFlowToEnd(runtime); - scheduleMessageFlowEndRestore(runtime); + scrollMessageFlowToEnd(runtime, messageFlowManualScrollBehavior(runtime, false)); } break; case "scroll-by": - scrollMessageFlowBy(runtime, messageFlowScrollDelta(runtime, command.amount, command.direction)); + scrollMessageFlowBy( + runtime, + messageFlowScrollDelta(runtime, command.amount, command.direction, command.repeated === true), + messageFlowManualScrollBehavior(runtime, command.repeated === true), + ); break; } } @@ -229,34 +235,64 @@ function handleMessageFlowBlockLayout(runtime: MessageFlowRuntime, element: HTML scheduleMessageFlowEndRestore(runtime); } -function scrollMessageFlowBy(runtime: MessageFlowRuntime, delta: number): void { +function scrollMessageFlowBy(runtime: MessageFlowRuntime, delta: number, behavior: ScrollBehavior = "auto"): void { const container = runtime.container; if (!container) return; - container.scrollTop += delta; - runtime.followingEnd = isMessageFlowAtEnd(container); + const targetTop = clampMessageFlowScrollTop(container, container.scrollTop + delta); + scrollMessageFlowToTop(container, targetTop, behavior); + runtime.followingEnd = isMessageFlowTopAtEnd(container, targetTop); } -function scrollMessageFlowToStart(runtime: MessageFlowRuntime): void { +function scrollMessageFlowToStart(runtime: MessageFlowRuntime, behavior: ScrollBehavior = "auto"): void { const container = runtime.container; if (!container) return; - container.scrollTop = 0; + scrollMessageFlowToTop(container, 0, behavior); runtime.followingEnd = false; } -function messageFlowScrollDelta(runtime: MessageFlowRuntime, amount: "text-lines" | "page", direction: MessageScrollDirection): number { +function messageFlowScrollDelta( + runtime: MessageFlowRuntime, + amount: "text-lines" | "page", + direction: MessageScrollDirection, + repeated: boolean, +): number { const container = runtime.container; if (!container) return 0; if (amount === "page") return Math.max(1, Math.floor(container.clientHeight * 0.8)) * direction; - return Math.max(1, Math.round(textLineHeight(container) * 2)) * direction; + const lines = repeated ? MESSAGE_FLOW_REPEATED_TEXT_LINE_SCROLL_LINES : MESSAGE_FLOW_TEXT_LINE_SCROLL_LINES; + return Math.max(1, Math.round(textLineHeight(container) * lines)) * direction; } -function scrollMessageFlowToEnd(runtime: MessageFlowRuntime): void { +function scrollMessageFlowToEnd(runtime: MessageFlowRuntime, behavior: ScrollBehavior = "auto"): void { const container = runtime.container; if (!container) return; - container.scrollTop = Math.max(0, container.scrollHeight - container.clientHeight); + scrollMessageFlowToTop(container, messageFlowEndScrollTop(container), behavior); runtime.followingEnd = true; } +function scrollMessageFlowToTop(container: HTMLElement, top: number, behavior: ScrollBehavior): void { + if (behavior === "smooth") { + container.scrollTo({ top, behavior }); + return; + } + + container.scrollTop = top; +} + +function messageFlowManualScrollBehavior(runtime: MessageFlowRuntime, repeated: boolean): ScrollBehavior { + if (repeated) return "auto"; + const win = runtime.container?.win; + return win?.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth"; +} + +function messageFlowEndScrollTop(container: HTMLElement): number { + return Math.max(0, container.scrollHeight - container.clientHeight); +} + +function clampMessageFlowScrollTop(container: HTMLElement, top: number): number { + return Math.max(0, Math.min(top, messageFlowEndScrollTop(container))); +} + function scheduleMessageFlowEndRestore(runtime: MessageFlowRuntime): void { const container = runtime.container; if (!container || runtime.restoreFrame !== null) return; @@ -307,7 +343,11 @@ function messageFlowBlockElements(container: HTMLElement): HTMLElement[] { } function isMessageFlowAtEnd(container: HTMLElement): boolean { - return container.scrollHeight - container.clientHeight - container.scrollTop <= 4; + return isMessageFlowTopAtEnd(container, container.scrollTop); +} + +function isMessageFlowTopAtEnd(container: HTMLElement, top: number): boolean { + return messageFlowEndScrollTop(container) - top <= 4; } function isMessageFlowViewportHidden(container: HTMLElement): boolean { diff --git a/tests/features/chat/conversation/composer/boundary-scroll.test.ts b/tests/features/chat/conversation/composer/boundary-scroll.test.ts index ac7d1670..ff4a6061 100644 --- a/tests/features/chat/conversation/composer/boundary-scroll.test.ts +++ b/tests/features/chat/conversation/composer/boundary-scroll.test.ts @@ -24,6 +24,21 @@ describe("composer boundary scroll shortcuts", () => { }); }); + it("marks repeated text-line scrolling", () => { + expect(direction("ArrowDown", "first\nsecond", 9, { repeat: true })).toEqual({ + kind: "scroll-by", + direction: 1, + amount: "text-lines", + repeated: true, + }); + expect(direction("n", "first\nsecond", 9, { ctrlKey: true, repeat: true })).toEqual({ + kind: "scroll-by", + direction: 1, + amount: "text-lines", + repeated: true, + }); + }); + it("scrolls by page from any composer line for PageUp and PageDown", () => { expect(direction("PageUp", "first\nsecond", 9)).toEqual({ kind: "scroll-by", direction: -1, amount: "page" }); expect(direction("PageDown", "first\nsecond", 3)).toEqual({ kind: "scroll-by", direction: 1, amount: "page" }); @@ -32,6 +47,12 @@ describe("composer boundary scroll shortcuts", () => { direction: 1, amount: "page", }); + expect(direction("PageDown", "first\nsecond", 3, { repeat: true })).toEqual({ + kind: "scroll-by", + direction: 1, + amount: "page", + repeated: true, + }); }); it("scrolls to stream edges from any composer line for Home and End", () => { @@ -75,6 +96,7 @@ function direction( altKey: boolean; shiftKey: boolean; isComposing: boolean; + repeat: boolean; selectionEnd: number; visualBoundary: boolean | ((direction: -1 | 1) => boolean); }> = {}, @@ -89,6 +111,7 @@ function direction( return composerBoundaryScrollDirection( { key, + repeat: options.repeat ?? false, ctrlKey: options.ctrlKey ?? false, metaKey: options.metaKey ?? false, altKey: options.altKey ?? false, diff --git a/tests/features/chat/ui/message-stream/flow-scroll.test.ts b/tests/features/chat/ui/message-stream/flow-scroll.test.ts index 8ce8cbc8..55f817cf 100644 --- a/tests/features/chat/ui/message-stream/flow-scroll.test.ts +++ b/tests/features/chat/ui/message-stream/flow-scroll.test.ts @@ -26,6 +26,7 @@ describe("message stream flow scrolling", () => { return animationFrameCallbacks.length; }) as typeof window.requestAnimationFrame; window.cancelAnimationFrame = (() => undefined) as typeof window.cancelAnimationFrame; + window.matchMedia = createTestMatchMedia(false); }); afterEach(() => { @@ -34,15 +35,18 @@ describe("message stream flow scrolling", () => { it("pins to the DOM scroll end and follows appended content while pinned", () => { const { controller, messages, render } = renderFlowMessageStream(["first"], { first: 300 }); + const scrollCalls = installScrollToCapture(messages); void act(() => { controller.dispatch({ kind: "show-latest" }); }); expect(messages.scrollTop).toBe(200); + expect(scrollCalls).toEqual([]); render(["first", "second"], { first: 300, second: 180 }); expect(messages.scrollTop).toBe(380); + expect(scrollCalls).toEqual([]); }); it("keeps the current reading position when content is appended after the user scrolls away", () => { @@ -168,6 +172,7 @@ describe("message stream flow scrolling", () => { it("scrolls by composer text-line and page commands", () => { const { controller, messages } = renderFlowMessageStream(["first", "second"], { first: 300, second: 300 }); + const scrollCalls = installScrollToCapture(messages); messages.style.lineHeight = "20px"; messages.scrollTop = 240; messages.dispatchEvent(new Event("scroll")); @@ -175,16 +180,67 @@ describe("message stream flow scrolling", () => { void act(() => { controller.dispatch({ kind: "scroll-by", amount: "text-lines", direction: -1 }); }); - expect(messages.scrollTop).toBe(200); + expect(messages.scrollTop).toBe(160); + expect(scrollCalls).toEqual([{ top: 160, behavior: "smooth" }]); void act(() => { controller.dispatch({ kind: "scroll-by", amount: "page", direction: 1 }); }); - expect(messages.scrollTop).toBe(280); + expect(messages.scrollTop).toBe(240); + expect(scrollCalls).toEqual([ + { top: 160, behavior: "smooth" }, + { top: 240, behavior: "smooth" }, + ]); + }); + + it("uses instant composer scrolling when reduced motion is preferred", () => { + window.matchMedia = createTestMatchMedia(true); + const { controller, messages } = renderFlowMessageStream(["first", "second"], { first: 300, second: 300 }); + const scrollCalls = installScrollToCapture(messages); + messages.style.lineHeight = "20px"; + messages.scrollTop = 240; + messages.dispatchEvent(new Event("scroll")); + + void act(() => { + controller.dispatch({ kind: "scroll-by", amount: "text-lines", direction: -1 }); + }); + + expect(messages.scrollTop).toBe(160); + expect(scrollCalls).toEqual([]); + }); + + it("uses the normal text-line distance for repeated composer scrolling without smooth animation", () => { + const { controller, messages } = renderFlowMessageStream(["first", "second"], { first: 300, second: 300 }); + const scrollCalls = installScrollToCapture(messages); + messages.style.lineHeight = "20px"; + messages.scrollTop = 240; + messages.dispatchEvent(new Event("scroll")); + + void act(() => { + controller.dispatch({ kind: "scroll-by", amount: "text-lines", direction: -1, repeated: true }); + }); + + expect(messages.scrollTop).toBe(160); + expect(scrollCalls).toEqual([]); + }); + + it("uses the normal page distance for repeated composer page scrolling without smooth animation", () => { + const { controller, messages } = renderFlowMessageStream(["first", "second"], { first: 300, second: 300 }); + const scrollCalls = installScrollToCapture(messages); + messages.scrollTop = 240; + messages.dispatchEvent(new Event("scroll")); + + void act(() => { + controller.dispatch({ kind: "scroll-by", amount: "page", direction: 1, repeated: true }); + }); + + expect(messages.scrollTop).toBe(320); + expect(scrollCalls).toEqual([]); }); it("scrolls to stream edges from composer commands", () => { const { controller, messages } = renderFlowMessageStream(["first", "second"], { first: 300, second: 300 }); + const scrollCalls = installScrollToCapture(messages); messages.scrollTop = 240; messages.dispatchEvent(new Event("scroll")); @@ -192,14 +248,24 @@ describe("message stream flow scrolling", () => { controller.dispatch({ kind: "scroll-to", edge: "start" }); }); expect(messages.scrollTop).toBe(0); + expect(scrollCalls).toEqual([{ top: 0, behavior: "smooth" }]); void act(() => { controller.dispatch({ kind: "scroll-to", edge: "end" }); }); expect(messages.scrollTop).toBe(500); + expect(scrollCalls).toEqual([ + { top: 0, behavior: "smooth" }, + { top: 500, behavior: "smooth" }, + ]); }); }); +interface CapturedScrollToOptions { + top: number | undefined; + behavior: ScrollBehavior | undefined; +} + interface TestMessageStreamScrollController extends MessageStreamScrollControllerBinding { dispatch(command: MessageStreamScrollCommand): void; } @@ -323,10 +389,34 @@ function installFlowMetrics( }); } +function installScrollToCapture(messages: HTMLElement): CapturedScrollToOptions[] { + const calls: CapturedScrollToOptions[] = []; + messages.scrollTo = ((optionsOrX?: ScrollToOptions | number, y?: number) => { + const top = typeof optionsOrX === "number" ? y : optionsOrX?.top; + const behavior = typeof optionsOrX === "number" ? undefined : optionsOrX?.behavior; + calls.push({ top, behavior }); + if (top !== undefined) messages.scrollTop = top; + }) as typeof messages.scrollTo; + return calls; +} + const messageScrollTop = new WeakMap(); let resizeObserverCallbacks: ResizeObserverCallback[] = []; let animationFrameCallbacks: FrameRequestCallback[] = []; +function createTestMatchMedia(matches: boolean): typeof window.matchMedia { + return ((query: string) => ({ + matches, + media: query, + onchange: null, + addEventListener: () => undefined, + removeEventListener: () => undefined, + addListener: () => undefined, + removeListener: () => undefined, + dispatchEvent: () => false, + })) as typeof window.matchMedia; +} + class TestResizeObserver implements ResizeObserver { private readonly callback: ResizeObserverCallback;