diff --git a/docs/HOOKS.md b/docs/HOOKS.md index 7cc4862a..36c4cc45 100644 --- a/docs/HOOKS.md +++ b/docs/HOOKS.md @@ -52,16 +52,19 @@ function useToolMessage(options: ToolMessageProps): ToolMessageState; Keeps the scroll container pinned to the bottom while messages stream in, but yields to the user as soon as they scroll up. Used by `MessageList`. ```typescript -function useSmartScroll(options?: { - threshold?: number; // px from bottom to be "at bottom" — default 50 - enabled?: boolean; +function useSmartScroll(options: { + isStreaming?: boolean; // pin to the growing last row while a response streams in + messagesCount: number; // drives auto-scroll when a message is appended + status?: ChatStatus; // a status transition also scrolls to the bottom + autoScroll?: boolean; // false disables every automatic trigger — default true }): { - ref: React.RefObject; - isAtBottom: boolean; - scrollToBottom: () => void; + containerRef: React.RefObject; + scrollToBottom: (behavior?: ScrollBehavior) => void; }; ``` +Attach `containerRef` to the scrollable element. `autoScroll` gates only the automatic triggers. `useSmartScroll` still returns `scrollToBottom`, so a direct hook consumer can drive the scroll from code — note it remains subject to the user-scrolled-up guard, so it is a no-op exactly while the user has scrolled away from the bottom. `MessageList` (which uses this hook internally) does not currently expose it. + ## `useScrollPreservation` Preserves a container's visual scroll position when items are prepended (typical for infinite-scroll-up message history). diff --git a/llms-full.txt b/llms-full.txt index 231115ee..cba36ad1 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -928,12 +928,13 @@ Defined under `.g-root`, applied regardless of theme. ### Empty Container -| Variable | Default | Description | -| --------------------------------------- | -------------------------------- | -------------------------- | -| `--g-aikit-empty-container-background` | `var(--g-color-base-background)` | Empty-state background | -| `--g-aikit-empty-container-content-gap` | `48px` | Gap between content blocks | -| `--g-aikit-empty-container-padding` | `48px 32px` | Empty-state padding | -| `--g-aikit-empty-container-welcome-gap` | `var(--g-spacing-6)` | Gap between hero and text | +| Variable | Default | Description | +| ---------------------------------------------- | -------------------------------- | -------------------------------------------- | +| `--g-aikit-empty-container-background` | `var(--g-color-base-background)` | Empty-state background | +| `--g-aikit-empty-container-content-gap` | `48px` | Gap between content blocks | +| `--g-aikit-empty-container-padding` | `48px 32px` | Empty-state padding | +| `--g-aikit-empty-container-welcome-gap` | `var(--g-spacing-6)` | Gap between hero and text | +| `--g-aikit-empty-container-content-overflow-y` | `auto` | Vertical overflow of the empty-state content | Mobile mode uses a parallel set of tokens, applied by the component itself: @@ -1088,16 +1089,19 @@ function useToolMessage(options: ToolMessageProps): ToolMessageState; Keeps the scroll container pinned to the bottom while messages stream in, but yields to the user as soon as they scroll up. Used by `MessageList`. ```typescript -function useSmartScroll(options?: { - threshold?: number; // px from bottom to be "at bottom" — default 50 - enabled?: boolean; +function useSmartScroll(options: { + isStreaming?: boolean; // pin to the growing last row while a response streams in + messagesCount: number; // drives auto-scroll when a message is appended + status?: ChatStatus; // a status transition also scrolls to the bottom + autoScroll?: boolean; // false disables every automatic trigger — default true }): { - ref: React.RefObject; - isAtBottom: boolean; - scrollToBottom: () => void; + containerRef: React.RefObject; + scrollToBottom: (behavior?: ScrollBehavior) => void; }; ``` +Attach `containerRef` to the scrollable element. `autoScroll` gates only the automatic triggers. `useSmartScroll` still returns `scrollToBottom`, so a direct hook consumer can drive the scroll from code — note it remains subject to the user-scrolled-up guard, so it is a no-op exactly while the user has scrolled away from the bottom. `MessageList` (which uses this hook internally) does not currently expose it. + ## `useScrollPreservation` Preserves a container's visual scroll position when items are prepended (typical for infinite-scroll-up message history). diff --git a/src/components/organisms/MessageList/MessageList.tsx b/src/components/organisms/MessageList/MessageList.tsx index 2cabe567..e539478b 100644 --- a/src/components/organisms/MessageList/MessageList.tsx +++ b/src/components/organisms/MessageList/MessageList.tsx @@ -115,6 +115,19 @@ export type MessageListProps = { virtualized?: boolean; /** Last scrollable row rendered after all messages. */ footerContent?: React.ReactNode; + /** + * Keeps the list pinned to the bottom as the conversation advances: on mount, when a message + * is appended, when the chat `status` changes, while a response streams in, and when the + * scroll viewport resizes. Set to `false` to leave the scroll position entirely under the + * user's control. + * + * Scrolling is always suspended while the user has scrolled up, regardless of this prop. + * Re-enabling takes effect at the next scroll trigger; it does not scroll to the bottom + * immediately. + * + * @default true + */ + autoScroll?: boolean; }; export function MessageList( @@ -155,6 +168,7 @@ function PlainMessageList({ ratingBlockProps, actionPopupProps, footerContent, + autoScroll, }: MessageListProps) { const isStreaming = status === 'streaming' || status === 'streaming_loading'; const isSubmitted = status === 'submitted'; @@ -170,6 +184,7 @@ function PlainMessageList({ isStreaming: isStreaming || isSubmitted, messagesCount: messages.length, status, + autoScroll, }); // Preserve scroll position when older messages are loaded diff --git a/src/components/organisms/MessageList/MessageList.virtualized.tsx b/src/components/organisms/MessageList/MessageList.virtualized.tsx index 424b1bae..3ed7d441 100644 --- a/src/components/organisms/MessageList/MessageList.virtualized.tsx +++ b/src/components/organisms/MessageList/MessageList.virtualized.tsx @@ -121,6 +121,7 @@ export function VirtualizedMessageList ratingBlockProps, actionPopupProps, footerContent, + autoScroll, }: MessageListProps) { const isStreaming = status === 'streaming' || status === 'streaming_loading'; const isSubmitted = status === 'submitted'; @@ -209,6 +210,7 @@ export function VirtualizedMessageList // growing last row even while react-window keeps the same set of rows mounted. streamingSignal: isNotCompleted ? messages[messages.length - 1] : undefined, trailingContentSignal: footerOffset, + autoScroll, }); // Close a popup whose anchor row scrolls out of view (react-window unmounts off-screen rows, diff --git a/src/components/organisms/MessageList/README.md b/src/components/organisms/MessageList/README.md index 600b7969..0c99da19 100644 --- a/src/components/organisms/MessageList/README.md +++ b/src/components/organisms/MessageList/README.md @@ -396,6 +396,7 @@ import {MessageList} from '@/components/organisms'; | `ratingBlockProps` | `RatingBlockProps` | - | - | Rating block configuration (for CSAT or other feedback use cases) - renders after messages list | | `actionPopupProps` | `MessageListActionPopupConfig` | - | - | Global configuration for action popups (title, subtitle, placement, className, qa) | | `virtualized` | `boolean` | - | `false` | Enable windowed rendering via `react-window` for very large histories. Requires a height-constrained container. | +| `autoScroll` | `boolean` | - | `true` | Keep the list pinned to the bottom as the conversation advances. `false` leaves scroll position entirely under the user's control | | `hasPreviousMessages` | `boolean` | - | `false` | Whether there are older messages to load (shows scroll trigger with loader) | | `onLoadPreviousMessages` | `() => void` | - | - | Callback to load previous messages when user scrolls to the top | | `className` | `string` | - | - | Additional CSS class | @@ -417,6 +418,30 @@ implementations share this behavior. Keep its outer box size stable while changi states so scroll anchoring remains predictable. The row is aligned to the left edge, matching the assistant-message column. +### Auto-scroll + +Keeps the list pinned to the bottom as the conversation advances: on mount, when a message is +appended, when the chat `status` changes, while a response streams in, and when the scroll +viewport resizes (the mobile on-screen keyboard). It already yields as soon as the user scrolls +up. + +`autoScroll={false}` switches all of that off and leaves scroll position entirely to the user: + +```tsx + +``` + +Note the consequence: with auto-scroll off, a chat with history opens scrolled to the **top**, not +at the last message. The switch is all-or-nothing. + +Preserving the viewport when older messages are prepended is anti-jump behavior, so it is +deliberately not affected by this prop and always applies. + +`useSmartScroll` (the hook backing this component) still returns `scrollToBottom`, so a direct +hook consumer can drive the scroll from code — note it remains subject to the user-scrolled-up +guard, so it is a no-op exactly while the user has scrolled away from the bottom. `MessageList` +does not currently expose it. + ## Styling | Variable | Description | diff --git a/src/components/pages/ChatContainer/ChatContainer.tsx b/src/components/pages/ChatContainer/ChatContainer.tsx index 1d8be059..40b85c04 100644 --- a/src/components/pages/ChatContainer/ChatContainer.tsx +++ b/src/components/pages/ChatContainer/ChatContainer.tsx @@ -199,6 +199,7 @@ export function ChatContainer(props: ChatContainerProps) { openMarkdownLinksInNewTab, mdxProps, messageListConfig, + autoScroll, mascotConfig, headerProps = {}, contentProps = {}, @@ -483,6 +484,7 @@ export function ChatContainer(props: ChatContainerProps) { const messageListProps = useMemo( () => ({ ...messageListConfig, + autoScroll, messages, status, errorMessage: texts.errorText @@ -524,6 +526,7 @@ export function ChatContainer(props: ChatContainerProps) { openMarkdownLinksInNewTab, mdxProps, messageListConfig, + autoScroll, qaMap, texts.errorText, mascotNode, diff --git a/src/components/pages/ChatContainer/README.md b/src/components/pages/ChatContainer/README.md index 7a988ac9..e5bfb6c9 100644 --- a/src/components/pages/ChatContainer/README.md +++ b/src/components/pages/ChatContainer/README.md @@ -418,6 +418,7 @@ Use the `texts` prop with type `ChatContainerTexts` for a **flat** API over user | `transformOptions` | `OptionsType` | - | - | Transform options for markdown rendering | | `openMarkdownLinksInNewTab` | `boolean` | - | `false` | Open markdown links rendered by default message renderers in a new tab, except hash-only and relative same-document anchors | | `messageListConfig` | `MessageListConfig` | - | - | Configuration for MessageList (actions, loader statuses) | +| `autoScroll` | `boolean` | - | `true` | Keep the message list pinned to the bottom as the conversation advances. See **Auto-scroll** below | | `headerProps` | `Partial` | - | - | Props override for Header component | | `contentProps` | `Partial` | - | - | Props override for ChatContent component | | `emptyContainerProps` | `Partial` | - | - | Props override for EmptyContainer | @@ -439,6 +440,28 @@ Use the `texts` prop with type `ChatContainerTexts` for a **flat** API over user | `footerClassName` | `string` | - | - | Additional CSS class for footer section | | `qa` | `string \| ChatContainerQa` | - | - | QA/test identifiers: string = root only; object = map or `prefix` (see **QA**) | +### Auto-scroll + +Keeps the message list pinned to the bottom as the conversation advances: on mount, when a +message is appended, when the chat `status` changes, while a response streams in, and when the +scroll viewport resizes (the mobile on-screen keyboard). + +```tsx + +``` + +`autoScroll={false}` switches all of that off and leaves scroll position entirely to the user. +Note the consequence: with auto-scroll off, a chat opened with existing history starts scrolled to +the **top** rather than at the last message. The switch is all-or-nothing. + +Two behaviors are deliberately not affected by this prop, in either direction: the guard that +suspends further auto-scrolling once the user has scrolled up keeps tracking scroll position +regardless of `autoScroll`, and preserving the viewport when older messages are prepended (via +`messageListConfig.onLoadPreviousMessages`) is anti-jump behavior that always applies. + +See the [MessageList README](../../organisms/MessageList/README.md#auto-scroll) for the +underlying implementation this wires into. + ## Types ### ChatContainerQa diff --git a/src/components/pages/ChatContainer/__stories__/ChatContainer.stories.tsx b/src/components/pages/ChatContainer/__stories__/ChatContainer.stories.tsx index 4f21e520..3fbe93b3 100644 --- a/src/components/pages/ChatContainer/__stories__/ChatContainer.stories.tsx +++ b/src/components/pages/ChatContainer/__stories__/ChatContainer.stories.tsx @@ -25,6 +25,7 @@ export { WithSuggestionDataCallback, WithMessages, WithStreaming, + WithAutoScrollDisabled, WithVirtualizedStreaming, VirtualizationComparison, WithMarkdownLinksInNewTab, diff --git a/src/components/pages/ChatContainer/__stories__/parts/basic.tsx b/src/components/pages/ChatContainer/__stories__/parts/basic.tsx index de1d7bb9..d49c16d1 100644 --- a/src/components/pages/ChatContainer/__stories__/parts/basic.tsx +++ b/src/components/pages/ChatContainer/__stories__/parts/basic.tsx @@ -712,3 +712,81 @@ export const WithHistory: Story = { }, decorators: defaultDecorators, }; + +/** + * Streaming with `autoScroll={false}`. + * + * The list never scrolls itself: it does not jump to the last message on open, does not follow + * new messages, and does not chase the answer while it streams in. Scroll position stays entirely + * under the user's control. + */ +export const WithAutoScrollDisabled: Story = { + args: { + showActionsOnHover: true, + autoScroll: false, + }, + render: (args) => { + const [messages, setMessages] = useState(() => createLargeHistory(20)); + const [status, setStatus] = useState('ready'); + + const handleSendMessage = async (data: TSubmitData) => { + const userMessageId = createMessageId('user'); + setMessages((prev) => [ + ...prev, + { + id: userMessageId, + role: 'user', + content: data.content, + actions: createMessageActions(userMessageId, 'user'), + }, + ]); + + setStatus('streaming'); + + const assistantMessageId = createMessageId('assistant'); + const fullResponse = + 'This response is long on purpose. With `autoScroll={false}` the viewport stays ' + + 'exactly where you left it while these words arrive, instead of being dragged to ' + + 'the bottom on every token. Note that the chat also opened at the top rather than ' + + 'at the last message - that is the same switch at work.'; + + setMessages((prev) => [ + ...prev, + { + id: assistantMessageId, + role: 'assistant', + content: '', + actions: createMessageActions(assistantMessageId, 'assistant'), + }, + ]); + + const words = fullResponse.split(' '); + for (let i = 0; i < words.length; i++) { + await new Promise((resolve) => setTimeout(resolve, 100)); + const currentText = words.slice(0, i + 1).join(' '); + setMessages((prev) => + prev.map((msg) => + msg.id === assistantMessageId ? {...msg, content: currentText} : msg, + ), + ); + } + + setStatus('ready'); + }; + + const handleCancel = async () => { + setStatus('ready'); + }; + + return ( + + ); + }, + decorators: defaultDecorators, +}; diff --git a/src/components/pages/ChatContainer/types.ts b/src/components/pages/ChatContainer/types.ts index fd82333c..5edabab3 100644 --- a/src/components/pages/ChatContainer/types.ts +++ b/src/components/pages/ChatContainer/types.ts @@ -82,6 +82,7 @@ export type MessageListConfig = Omit< | 'transformOptions' | 'openMarkdownLinksInNewTab' | 'mdxProps' + | 'autoScroll' >; /** @@ -258,6 +259,19 @@ export interface ChatContainerProps { // Configuration /** MessageList configuration for actions and loader behavior */ messageListConfig?: MessageListConfig; + /** + * Keeps the message list pinned to the bottom as the conversation advances: on mount, when a + * message is appended, when the chat `status` changes, while a response streams in, and when + * the scroll viewport resizes. Set to `false` to leave the scroll position entirely under the + * user's control. + * + * Scrolling is always suspended while the user has scrolled up, regardless of this prop. + * Re-enabling takes effect at the next scroll trigger; it does not scroll to the bottom + * immediately. + * + * @default true + */ + autoScroll?: boolean; /** Mascot renderer, assets and lifecycle configuration. */ mascotConfig?: MascotConfig; diff --git a/src/hooks/__tests__/useSmartScroll.unit.test.tsx b/src/hooks/__tests__/useSmartScroll.unit.test.tsx new file mode 100644 index 00000000..c4923abe --- /dev/null +++ b/src/hooks/__tests__/useSmartScroll.unit.test.tsx @@ -0,0 +1,128 @@ +import {act, render} from '@testing-library/react'; + +import type {ChatStatus} from '../../types'; +import {type UseSmartScrollReturn, useSmartScroll} from '../useSmartScroll'; + +type HarnessProps = { + messagesCount: number; + status?: ChatStatus; + isStreaming?: boolean; + autoScroll?: boolean; +}; + +let hookResult: UseSmartScrollReturn; + +function Harness(props: HarnessProps) { + const result = useSmartScroll(props); + hookResult = result; + return
; +} + +const mutateContainer = async () => { + await act(async () => { + hookResult.containerRef.current?.appendChild(document.createTextNode('token')); + // MutationObserver callbacks are queued as microtasks. + await Promise.resolve(); + }); +}; + +describe('useSmartScroll', () => { + let scrollTo: jest.Mock; + + beforeEach(() => { + scrollTo = jest.fn(); + Element.prototype.scrollTo = scrollTo; + }); + + it('should scroll to the bottom on mount by default', () => { + render(); + expect(scrollTo).toHaveBeenCalledWith({top: expect.any(Number), behavior: 'instant'}); + }); + + it('should not scroll on mount when auto-scroll is disabled', () => { + render(); + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it('should scroll when a message is appended', () => { + const {rerender} = render(); + scrollTo.mockClear(); + rerender(); + expect(scrollTo).toHaveBeenCalledWith({top: expect.any(Number), behavior: 'smooth'}); + }); + + it('should not scroll on a new message when auto-scroll is disabled', () => { + const {rerender} = render(); + scrollTo.mockClear(); + rerender(); + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it('should scroll on a status change', () => { + const {rerender} = render(); + scrollTo.mockClear(); + rerender(); + expect(scrollTo).toHaveBeenCalledWith({top: expect.any(Number), behavior: 'smooth'}); + }); + + it('should not scroll on a status change when auto-scroll is disabled', () => { + const {rerender} = render( + , + ); + scrollTo.mockClear(); + rerender(); + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it('should scroll when the DOM mutates while streaming', async () => { + render(); + scrollTo.mockClear(); + await mutateContainer(); + expect(scrollTo).toHaveBeenCalledWith({top: expect.any(Number), behavior: 'instant'}); + }); + + it('should not scroll on a streaming mutation when auto-scroll is disabled', async () => { + render(); + scrollTo.mockClear(); + await mutateContainer(); + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it('should keep the imperative scrollToBottom working while auto-scroll is disabled', () => { + render(); + expect(scrollTo).not.toHaveBeenCalled(); + + hookResult.scrollToBottom(); + + expect(scrollTo).toHaveBeenCalledTimes(1); + }); + + it('should keep tracking user scroll while auto-scroll is disabled', () => { + render(); + const container = hookResult.containerRef.current as HTMLDivElement; + Object.defineProperty(container, 'scrollHeight', {value: 1000, configurable: true}); + Object.defineProperty(container, 'clientHeight', {value: 100, configurable: true}); + container.scrollTop = 200; + + act(() => { + container.dispatchEvent(new Event('scroll')); + }); + + // distanceFromBottom = 1000 - 200 - 100 = 700, well past SCROLL_THRESHOLD, so the listener + // must have recorded the scroll-up - which the imperative scrollToBottom then respects. + hookResult.scrollToBottom(); + + expect(scrollTo).not.toHaveBeenCalled(); + }); + + it('should not scroll when only auto-scroll flips from disabled to enabled', () => { + // No other prop changes here (status/messagesCount stay put), so if autoScroll were ever + // added to an effect's dependency array, that effect would spuriously re-fire on this + // transition and - since the ref is already updated by the time effects run - the call + // would go through uninhibited, unlike the disable direction where the ref masks it. + const {rerender} = render(); + scrollTo.mockClear(); + rerender(); + expect(scrollTo).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/__tests__/useVirtualStickToBottom.unit.test.tsx b/src/hooks/__tests__/useVirtualStickToBottom.unit.test.tsx new file mode 100644 index 00000000..fe7ed5e7 --- /dev/null +++ b/src/hooks/__tests__/useVirtualStickToBottom.unit.test.tsx @@ -0,0 +1,151 @@ +import {renderHook} from '@testing-library/react'; + +import { + type UseVirtualStickToBottomParams, + useVirtualStickToBottom, +} from '../useVirtualStickToBottom'; + +const scrollToRow = jest.fn(); +let listApi: {element: HTMLDivElement; scrollToRow: jest.Mock} | null = null; + +jest.mock('react-window', () => ({ + useListCallbackRef: () => [listApi, jest.fn()], +})); + +const renderStickHook = (initialProps: UseVirtualStickToBottomParams) => + renderHook((props: UseVirtualStickToBottomParams) => useVirtualStickToBottom(props), { + initialProps, + }); + +describe('useVirtualStickToBottom', () => { + beforeEach(() => { + scrollToRow.mockClear(); + listApi = {element: document.createElement('div'), scrollToRow}; + }); + + it('should pin to the bottom on mount by default', () => { + renderStickHook({rowCount: 3, messagesCount: 3}); + expect(scrollToRow).toHaveBeenCalledWith({index: 2, align: 'end', behavior: 'instant'}); + }); + + it('should not pin on mount when auto-scroll is disabled', () => { + renderStickHook({rowCount: 3, messagesCount: 3, autoScroll: false}); + expect(scrollToRow).not.toHaveBeenCalled(); + }); + + it('should pin when a message is appended', () => { + const {rerender} = renderStickHook({rowCount: 3, messagesCount: 3}); + scrollToRow.mockClear(); + rerender({rowCount: 4, messagesCount: 4}); + expect(scrollToRow).toHaveBeenCalledWith({index: 3, align: 'end', behavior: 'instant'}); + }); + + it('should not pin on a new message when auto-scroll is disabled', () => { + const {rerender} = renderStickHook({rowCount: 3, messagesCount: 3, autoScroll: false}); + scrollToRow.mockClear(); + rerender({rowCount: 4, messagesCount: 4, autoScroll: false}); + expect(scrollToRow).not.toHaveBeenCalled(); + }); + + it('should pin on a status change', () => { + const {rerender} = renderStickHook({rowCount: 3, messagesCount: 3, status: 'streaming'}); + scrollToRow.mockClear(); + rerender({rowCount: 3, messagesCount: 3, status: 'ready'}); + expect(scrollToRow).toHaveBeenCalledWith({index: 2, align: 'end', behavior: 'instant'}); + }); + + it('should not pin on a status change when auto-scroll is disabled', () => { + const {rerender} = renderStickHook({ + rowCount: 3, + messagesCount: 3, + status: 'streaming', + autoScroll: false, + }); + scrollToRow.mockClear(); + rerender({rowCount: 3, messagesCount: 3, status: 'ready', autoScroll: false}); + expect(scrollToRow).not.toHaveBeenCalled(); + }); + + it('should pin on a streaming tick', () => { + const {rerender} = renderStickHook({ + rowCount: 3, + messagesCount: 3, + isStreaming: true, + streamingSignal: 'token-1', + }); + scrollToRow.mockClear(); + rerender({ + rowCount: 3, + messagesCount: 3, + isStreaming: true, + streamingSignal: 'token-2', + }); + expect(scrollToRow).toHaveBeenCalledWith({index: 2, align: 'end', behavior: 'instant'}); + }); + + it('should not pin on a streaming tick when auto-scroll is disabled', () => { + const {rerender} = renderStickHook({ + rowCount: 3, + messagesCount: 3, + isStreaming: true, + streamingSignal: 'token-1', + autoScroll: false, + }); + scrollToRow.mockClear(); + rerender({ + rowCount: 3, + messagesCount: 3, + isStreaming: true, + streamingSignal: 'token-2', + autoScroll: false, + }); + expect(scrollToRow).not.toHaveBeenCalled(); + }); + + it('should not pin when only auto-scroll flips from disabled to enabled', () => { + // No other prop changes here (status/messagesCount stay put), so if autoScroll were ever + // added to an effect's dependency array, that effect would spuriously re-fire on this + // transition and - since the ref is already updated by the time effects run - the call + // would go through uninhibited, unlike the disable direction where the ref masks it. + const {rerender} = renderStickHook({ + rowCount: 3, + messagesCount: 3, + status: 'ready', + autoScroll: false, + }); + scrollToRow.mockClear(); + rerender({rowCount: 3, messagesCount: 3, status: 'ready', autoScroll: true}); + expect(scrollToRow).not.toHaveBeenCalled(); + }); + + it('should abandon an in-flight per-frame pin once autoScroll is disabled mid-flight', () => { + // pinToBottom re-applies scrollToRow across several animation frames (REANCHOR_FRAMES) to + // cope with react-window's estimated row heights. Capture the rAF callbacks so the test can + // drive that per-frame re-check directly, instead of only the entry check at effect time. + const rafCallbacks: FrameRequestCallback[] = []; + const rafSpy = jest + .spyOn(window, 'requestAnimationFrame') + .mockImplementation((callback) => { + rafCallbacks.push(callback); + return rafCallbacks.length; + }); + + // autoScroll defaults to true, so mount pins to the bottom and schedules the next frame. + const {rerender} = renderStickHook({rowCount: 3, messagesCount: 3}); + expect(scrollToRow).toHaveBeenCalled(); + expect(rafCallbacks.length).toBeGreaterThan(0); + + scrollToRow.mockClear(); + // Disable auto-scroll while the multi-frame correction is still in flight. No effect + // re-fires from this alone (autoScroll is read through a ref, not a dependency), so the + // previously scheduled frame is still the one that will run. + rerender({rowCount: 3, messagesCount: 3, autoScroll: false}); + + const pendingStep = rafCallbacks[rafCallbacks.length - 1]; + pendingStep(0); + + expect(scrollToRow).not.toHaveBeenCalled(); + + rafSpy.mockRestore(); + }); +}); diff --git a/src/hooks/useSmartScroll.tsx b/src/hooks/useSmartScroll.tsx index e805f9d5..d9dbd150 100644 --- a/src/hooks/useSmartScroll.tsx +++ b/src/hooks/useSmartScroll.tsx @@ -13,13 +13,25 @@ export function useSmartScroll({ isStreaming = false, messagesCount, status, + autoScroll = true, }: { isStreaming?: boolean; messagesCount: number; status?: ChatStatus; + /** + * Keep the container pinned to the bottom automatically. Set to `false` to leave the scroll + * position entirely under the user's control. Never gates the returned `scrollToBottom`. + * + * @default true + */ + autoScroll?: boolean; }): UseSmartScrollReturn { const containerRef = useRef(null); const userScrolledUpRef = useRef(false); + // Read at fire time rather than through effect dependencies, so that toggling the flag does + // not re-run - and so re-fire - the effects below. + const autoScrollRef = useRef(autoScroll); + autoScrollRef.current = autoScroll; const scrollToBottom = useCallback((behavior: ScrollBehavior = 'instant') => { if (!userScrolledUpRef.current) { @@ -33,12 +45,24 @@ export function useSmartScroll({ } }, []); + // Entry point for the automatic triggers. `scrollToBottom` itself stays ungated so consumers + // can keep driving the scroll imperatively while automatic scrolling is off. + const autoScrollToBottom = useCallback( + (behavior: ScrollBehavior = 'instant') => { + if (autoScrollRef.current) { + scrollToBottom(behavior); + } + }, + [scrollToBottom], + ); + // Initial scroll to bottom useEffect(() => { - scrollToBottom(); + autoScrollToBottom(); }, []); - // Handle user scroll events + // Handle user scroll events. Never gated: this tracks user intent, and it has to stay accurate + // while auto-scroll is off so that re-enabling it later does not resume from a stale state. useEffect(() => { const container = containerRef.current; if (!container) { @@ -72,13 +96,13 @@ export function useSmartScroll({ return undefined; } - const observer = new ResizeObserver(() => scrollToBottom('instant')); + const observer = new ResizeObserver(() => autoScrollToBottom('instant')); observer.observe(container); return () => { observer.disconnect(); }; - }, [scrollToBottom]); + }, [autoScrollToBottom]); // Handle DOM mutations during streaming useEffect(() => { @@ -88,7 +112,7 @@ export function useSmartScroll({ } const observer = new MutationObserver(() => { - scrollToBottom('instant'); + autoScrollToBottom('instant'); }); observer.observe(container, { @@ -101,18 +125,18 @@ export function useSmartScroll({ return () => { observer.disconnect(); }; - }, [isStreaming]); + }, [isStreaming, autoScrollToBottom]); // Handle status changes useEffect(() => { - scrollToBottom('smooth'); - }, [status]); + autoScrollToBottom('smooth'); + }, [status, autoScrollToBottom]); useEffect(() => { if (messagesCount) { - scrollToBottom('smooth'); + autoScrollToBottom('smooth'); } - }, [messagesCount]); + }, [messagesCount, autoScrollToBottom]); return { containerRef, diff --git a/src/hooks/useVirtualStickToBottom.ts b/src/hooks/useVirtualStickToBottom.ts index 3f7aee8f..ba1bc4fa 100644 --- a/src/hooks/useVirtualStickToBottom.ts +++ b/src/hooks/useVirtualStickToBottom.ts @@ -36,6 +36,13 @@ export interface UseVirtualStickToBottomParams { streamingSignal?: unknown; /** Value that changes when a trailing non-message row appears or disappears. */ trailingContentSignal?: unknown; + /** + * Keep the list pinned to the bottom automatically. Set to `false` to leave the scroll + * position entirely under the user's control. Does not affect the prepend/anti-jump restore. + * + * @default true + */ + autoScroll?: boolean; } /** @@ -61,12 +68,17 @@ export function useVirtualStickToBottom({ headerOffset = 0, streamingSignal, trailingContentSignal, + autoScroll = true, }: UseVirtualStickToBottomParams) { const [listApi, listRef] = useListCallbackRef(); // Drives a shimmer overlay while older messages are being prepended and the scroll is restored. const [isPrepending, setIsPrepending] = useState(false); const userScrolledUpRef = useRef(false); const prependingRef = useRef(false); + // Read at fire time rather than through effect dependencies, so that toggling the flag does + // not re-run - and so re-fire - the effects below. + const autoScrollRef = useRef(autoScroll); + autoScrollRef.current = autoScroll; const shimmerTimerRef = useRef>(); const prevFirstIdRef = useRef(firstMessageId); const prevMessagesCountRef = useRef(messagesCount); @@ -106,14 +118,24 @@ export function useVirtualStickToBottom({ // position computed from (partly estimated) row heights, so a single call lands short; once the // bottom rows render and are measured, the next frame re-targets the now-lower bottom. const pinToBottom = useCallback(() => { - if (userScrolledUpRef.current || prependingRef.current || rowCountRef.current <= 0) { + if ( + !autoScrollRef.current || + userScrolledUpRef.current || + prependingRef.current || + rowCountRef.current <= 0 + ) { return; } cancelPendingScroll(); let frame = 0; const step = () => { const api = listApiRef.current; - if (userScrolledUpRef.current || prependingRef.current || !api) { + if ( + !autoScrollRef.current || + userScrolledUpRef.current || + prependingRef.current || + !api + ) { return; } api.scrollToRow({index: rowCountRef.current - 1, align: 'end', behavior: 'instant'});