Skip to content
15 changes: 9 additions & 6 deletions docs/HOOKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T extends HTMLElement>(options?: {
threshold?: number; // px from bottom to be "at bottom" — default 50
enabled?: boolean;
function useSmartScroll<T extends HTMLElement>(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<T>;
isAtBottom: boolean;
scrollToBottom: () => void;
containerRef: React.RefObject<T>;
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).
Expand Down
28 changes: 16 additions & 12 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -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<T extends HTMLElement>(options?: {
threshold?: number; // px from bottom to be "at bottom" — default 50
enabled?: boolean;
function useSmartScroll<T extends HTMLElement>(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<T>;
isAtBottom: boolean;
scrollToBottom: () => void;
containerRef: React.RefObject<T>;
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).
Expand Down
15 changes: 15 additions & 0 deletions src/components/organisms/MessageList/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,19 @@ export type MessageListProps<TContent extends TMessageContent = never> = {
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<TContent extends TMessageContent = never>(
Expand Down Expand Up @@ -155,6 +168,7 @@ function PlainMessageList<TContent extends TMessageContent = never>({
ratingBlockProps,
actionPopupProps,
footerContent,
autoScroll,
}: MessageListProps<TContent>) {
const isStreaming = status === 'streaming' || status === 'streaming_loading';
const isSubmitted = status === 'submitted';
Expand All @@ -170,6 +184,7 @@ function PlainMessageList<TContent extends TMessageContent = never>({
isStreaming: isStreaming || isSubmitted,
messagesCount: messages.length,
status,
autoScroll,
});

// Preserve scroll position when older messages are loaded
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ export function VirtualizedMessageList<TContent extends TMessageContent = never>
ratingBlockProps,
actionPopupProps,
footerContent,
autoScroll,
}: MessageListProps<TContent>) {
const isStreaming = status === 'streaming' || status === 'streaming_loading';
const isSubmitted = status === 'submitted';
Expand Down Expand Up @@ -209,6 +210,7 @@ export function VirtualizedMessageList<TContent extends TMessageContent = never>
// 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,
Expand Down
25 changes: 25 additions & 0 deletions src/components/organisms/MessageList/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
<MessageList messages={messages} autoScroll={false} />
```

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 |
Expand Down
3 changes: 3 additions & 0 deletions src/components/pages/ChatContainer/ChatContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@
openMarkdownLinksInNewTab,
mdxProps,
messageListConfig,
autoScroll,
mascotConfig,
headerProps = {},
contentProps = {},
Expand Down Expand Up @@ -409,7 +410,7 @@
]);

// Build props for EmptyContainer
const finalEmptyContainerProps = useMemo(() => {

Check warning on line 413 in src/components/pages/ChatContainer/ChatContainer.tsx

View workflow job for this annotation

GitHub Actions / Verify Files

Arrow function has a complexity of 24. Maximum allowed is 20
const {showDefaultTitle = true, showDefaultDescription = true} = welcomeConfig || {};

return {
Expand Down Expand Up @@ -483,6 +484,7 @@
const messageListProps = useMemo(
() => ({
...messageListConfig,
autoScroll,
messages,
status,
errorMessage: texts.errorText
Expand Down Expand Up @@ -524,6 +526,7 @@
openMarkdownLinksInNewTab,
mdxProps,
messageListConfig,
autoScroll,
qaMap,
texts.errorText,
mascotNode,
Expand Down
23 changes: 23 additions & 0 deletions src/components/pages/ChatContainer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<HeaderProps>` | - | - | Props override for Header component |
| `contentProps` | `Partial<ChatContentProps>` | - | - | Props override for ChatContent component |
| `emptyContainerProps` | `Partial<EmptyContainerProps>` | - | - | Props override for EmptyContainer |
Expand All @@ -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
<ChatContainer autoScroll={false} messages={messages} onSendMessage={handleSendMessage} />
```

`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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export {
WithSuggestionDataCallback,
WithMessages,
WithStreaming,
WithAutoScrollDisabled,
WithVirtualizedStreaming,
VirtualizationComparison,
WithMarkdownLinksInNewTab,
Expand Down
78 changes: 78 additions & 0 deletions src/components/pages/ChatContainer/__stories__/parts/basic.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<TChatMessage[]>(() => createLargeHistory(20));
const [status, setStatus] = useState<ChatStatus>('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 (
<ChatContainer
{...args}
messages={messages}
onSendMessage={handleSendMessage}
onCancel={handleCancel}
status={status}
/>
);
},
decorators: defaultDecorators,
};
14 changes: 14 additions & 0 deletions src/components/pages/ChatContainer/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export type MessageListConfig = Omit<
| 'transformOptions'
| 'openMarkdownLinksInNewTab'
| 'mdxProps'
| 'autoScroll'
>;

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

Expand Down
Loading
Loading