diff --git a/apps/www/src/content/docs/ai-elements/chat/demo.ts b/apps/www/src/content/docs/ai-elements/chat/demo.ts new file mode 100644 index 000000000..df5092aa2 --- /dev/null +++ b/apps/www/src/content/docs/ai-elements/chat/demo.ts @@ -0,0 +1,130 @@ +'use client'; + +export const preview = { + type: 'code', + code: ` + + + Today 1:52 AM + + + + create a task in design system 2 + + + + + + + + + + + Looked at recent issues in Design System 2. + + + + + I created the task in Design System 2 and assigned it to you. + + + + + +
+ console.log(value)}> + + + + + +
+
+
` +}; + +export const attachmentDemo = { + type: 'code', + code: ` + {}} /> + + {}} /> +` +}; + +export const streamingDemo = { + type: 'code', + code: `function StreamingChat() { + const [messages, setMessages] = React.useState([ + { id: 'm1', align: 'end', text: 'What changed this week?' }, + { id: 'm2', align: 'start', text: 'Three issues moved to done.' } + ]); + const timersRef = React.useRef([]); + + React.useEffect(() => () => timersRef.current.forEach(clearTimeout), []); + + const send = value => { + const id = 'u' + Date.now(); + setMessages(current => [ + ...current, + { id, align: 'end', text: value, anchor: true }, + { id: id + '-reply', align: 'start', text: '' } + ]); + // Simulate a token stream into the reply. + const words = 'Sure — streaming replies keep the anchored question in view while text arrives below it, and the ↓ Latest pill appears once the live edge scrolls out of view.'.split(' '); + words.forEach((word, index) => { + timersRef.current.push( + setTimeout(() => { + setMessages(current => + current.map(message => + message.id === id + '-reply' + ? { ...message, text: message.text + ' ' + word } + : message + ) + ); + }, 120 * (index + 1)) + ); + }); + }; + + return ( + + + + {messages.map(message => ( + + + + {message.align === 'end' ? ( + {message.text} + ) : ( + {message.text} + )} + + + + ))} + + +
+ { + event.currentTarget.reset(); + send(value); + }} + > + + + + + +
+
+
+ ); +}` +}; diff --git a/apps/www/src/content/docs/ai-elements/chat/index.mdx b/apps/www/src/content/docs/ai-elements/chat/index.mdx new file mode 100644 index 000000000..a7c22455d --- /dev/null +++ b/apps/www/src/content/docs/ai-elements/chat/index.mdx @@ -0,0 +1,129 @@ +--- +title: Chat +description: The conversation scroller — live-edge following, turn anchoring, prepend preservation and visibility tracking, plus separators and attachments. +source: packages/raystack/components/chat +tag: new +--- + +import { preview, attachmentDemo, streamingDemo } from "./demo.ts"; + + + +## Anatomy + +```tsx +import { Chat, Message, Reasoning } from '@raystack/apsara' + + + + Today 1:52 AM + + + + create a task in design system 2 + + + + + + + + + + + + + {/* consumer-rendered markdown / chips / cards */} + + + + + + +``` + +`Chat` owns scroll behavior; layout comes from +[Message](/docs/ai-elements/message) and +[Reasoning](/docs/ai-elements/reasoning), composed as children. `Chat.Item` +is the bridge: a neutral wrapper that registers whatever it wraps — a +message, a separator, a tool-call card — with the scroller. Message bodies +are children: markdown rendering, entity chips and embedded cards are +composed by your app — no markdown dependency enters the design system. +Consumers own messages, streaming and sending; everything here is +presentational. + +## API Reference + +### Chat + +A flex-column container that sizes `Chat.Messages` against a composer sibling. + +### Chat.Messages + +The smart scroller. It follows streaming output while the reader is at the +bottom, stops following once they scroll up, anchors new `scrollAnchor` items +near the top of the viewport (ChatGPT/Linear style), and keeps the reading +position stable when older history is prepended. + + + +Imperative commands are available through `actionsRef`: + + + +### Chat.Item + +The per-item behavior unit. It registers its element with the enclosing +`Chat.Messages` for visibility tracking and `scrollToMessage`, marks the turn +anchor, and applies `content-visibility: auto` containment so offscreen items +skip layout and paint. It wraps anything — it has no opinion about what a +message looks like. + + + +### Chat.JumpButton + +The floating "↓ Latest" pill. It appears once the reader leaves the live edge +and scrolls back down on click. Render it as a child of `Chat.Messages`; +children replace the default arrow-and-label content. + +### useChatMessages + +Hook for components rendered inside `Chat.Messages` — active-turn indicators, +jump-to-message navigation, custom scroll buttons. + + + +### Chat.Separator + +A centered label between hairlines — "Today 1:52 AM". Purely presentational; +format dates however your app does. + +### Chat.Attachment + +A presentational attachment preview card for `PromptInput.Header` or message +bodies. File picking, drag-drop and validation belong to your app. + + + +## Examples + +### Attachments + + + +### Streaming and turn anchoring + +Send a message: it anchors near the top while the reply streams below, and +the "↓ Latest" pill returns you to the live edge after scrolling up. + + + +## Accessibility + +- The `Chat.Messages` viewport has `role="log"` with an accessible label, so + new messages are announced politely by screen readers. +- The jump button removes itself from the tab order (`tabindex="-1"`, + `aria-hidden`) while the reader is already at the live edge. +- Programmatic scrolling collapses to instant jumps under + `prefers-reduced-motion: reduce`. diff --git a/apps/www/src/content/docs/ai-elements/chat/props.ts b/apps/www/src/content/docs/ai-elements/chat/props.ts new file mode 100644 index 000000000..ff96e5a18 --- /dev/null +++ b/apps/www/src/content/docs/ai-elements/chat/props.ts @@ -0,0 +1,117 @@ +import type React from 'react'; + +export interface ChatMessagesProps { + /** + * Distance from the bottom, in pixels, within which the reader still + * counts as being at the live edge. + * @defaultValue 24 + */ + bottomThreshold?: number; + + /** + * Gap kept between the viewport top and an item anchored by + * `scrollAnchor` or scrolled to with `scrollToMessage`, in pixels. + * @defaultValue 12 + */ + anchorOffset?: number; + + /** + * Whether to follow new content while the reader is at the live edge. + * @defaultValue true + */ + autoScroll?: boolean; + + /** A ref populated with the imperative scroll commands. */ + actionsRef?: React.RefObject; + + /** + * Accessible label for the message log. + * @defaultValue "Conversation" + */ + 'aria-label'?: string; + + /** Custom CSS class names. */ + className?: string; +} + +export interface ChatMessagesActions { + /** Scrolls the conversation to the live edge. */ + scrollToBottom: (behavior?: ScrollBehavior) => void; + + /** Scrolls the item registered with the given `messageId` into view. */ + scrollToMessage: ( + id: string, + options?: { behavior?: ScrollBehavior } + ) => void; +} + +export interface UseChatMessagesReturn { + /** Whether the reader is at the live edge (scrolled to the bottom). */ + atBottom: boolean; + + /** + * Ids of the registered items currently intersecting the viewport, in + * document order. Only items given a `messageId` are tracked. + */ + visibleMessageIds: string[]; + + /** Scrolls the conversation to the live edge. */ + scrollToBottom: (behavior?: ScrollBehavior) => void; + + /** Scrolls the item registered with the given `messageId` into view. */ + scrollToMessage: ( + id: string, + options?: { behavior?: ScrollBehavior } + ) => void; +} + +export interface ChatItemProps { + /** + * Stable id registered with the enclosing `Chat.Messages`, enabling + * visibility tracking and `scrollToMessage`. + */ + messageId?: string; + + /** + * Marks the item as a scroll anchor: when it mounts inside + * `Chat.Messages`, the viewport scrolls it near the top so the reply can + * stream in below — set it on the latest user message. + * @defaultValue false + */ + scrollAnchor?: boolean; + + /** Custom CSS class names. */ + className?: string; +} + +export interface ChatAttachmentProps { + /** File name or main label. */ + title?: React.ReactNode; + + /** Secondary line — file size, type, or the error message. */ + description?: React.ReactNode; + + /** + * Content of the leading media square. Defaults to a file icon, or a + * spinner while `state` is `"uploading"`. + */ + media?: React.ReactNode; + + /** + * Lifecycle of the attachment. + * @defaultValue "done" + */ + state?: 'uploading' | 'error' | 'done'; + + /** When provided, renders a remove button that calls it. */ + onRemove?: () => void; + + /** + * Accessible label for the remove button. + * @defaultValue "Remove attachment" + */ + removeLabel?: string; + + /** Custom CSS class names. */ + className?: string; +} diff --git a/apps/www/src/content/docs/ai-elements/meta.json b/apps/www/src/content/docs/ai-elements/meta.json index 2a445eae9..ffc5c6461 100644 --- a/apps/www/src/content/docs/ai-elements/meta.json +++ b/apps/www/src/content/docs/ai-elements/meta.json @@ -1,4 +1,4 @@ { "title": "AI Elements", - "pages": ["message", "prompt-input", "reasoning"] + "pages": ["chat", "message", "prompt-input", "reasoning"] } diff --git a/packages/raystack/components/chat/__tests__/chat.test.tsx b/packages/raystack/components/chat/__tests__/chat.test.tsx new file mode 100644 index 000000000..04e6f24f2 --- /dev/null +++ b/packages/raystack/components/chat/__tests__/chat.test.tsx @@ -0,0 +1,177 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { createRef } from 'react'; +import { describe, expect, it, vi } from 'vitest'; +import { Chat } from '../chat'; +import styles from '../chat.module.css'; +import { type ChatMessagesActions, useChatMessages } from '../chat-context'; + +// Base UI's ScrollArea calls viewport.getAnimations() on scroll, which jsdom +// does not implement. Polyfilled here (per-file) rather than globally: an +// Element-wide polyfill changes how Base UI popups close in other suites. +if (typeof Element.prototype.getAnimations !== 'function') { + Element.prototype.getAnimations = () => []; +} + +describe('Chat', () => { + describe('Chat.Item', () => { + it('stamps the message id as a data attribute', () => { + render( + + hello + + ); + expect(screen.getByText('hello')).toHaveAttribute( + 'data-message-id', + 'm1' + ); + }); + + it('renders with the item class', () => { + render(hello); + expect(screen.getByTestId('item')).toHaveClass(styles.item); + }); + + it('renders outside Chat.Messages without registering', () => { + render(standalone); + expect(screen.getByText('standalone')).toBeInTheDocument(); + }); + + it('forwards ref', () => { + const ref = createRef(); + render(hi); + expect(ref.current).toBeInstanceOf(HTMLDivElement); + }); + }); + + describe('Chat.Separator', () => { + it('renders its children', () => { + render(Today 1:52 AM); + expect(screen.getByText('Today 1:52 AM')).toBeInTheDocument(); + }); + }); + + describe('Chat.Attachment', () => { + it('renders title and description', () => { + render(); + expect(screen.getByText('spec.pdf')).toBeInTheDocument(); + expect(screen.getByText('1.2 MB')).toBeInTheDocument(); + }); + + it('reflects the state as a data attribute', () => { + const { container, rerender } = render( + + ); + expect(container.firstElementChild).toHaveAttribute('data-state', 'done'); + rerender(); + expect(container.firstElementChild).toHaveAttribute( + 'data-state', + 'uploading' + ); + }); + + it('shows a remove button only when onRemove is provided', async () => { + const onRemove = vi.fn(); + const user = userEvent.setup(); + const { rerender } = render(); + expect( + screen.queryByRole('button', { name: 'Remove attachment' }) + ).not.toBeInTheDocument(); + + rerender(); + await user.click( + screen.getByRole('button', { name: 'Remove attachment' }) + ); + expect(onRemove).toHaveBeenCalledTimes(1); + }); + }); + + describe('Chat.Messages', () => { + it('renders children inside an accessible log', () => { + render( + + hello + + ); + const log = screen.getByRole('log', { name: 'Conversation' }); + expect(log).toBeInTheDocument(); + expect(screen.getByText('hello')).toBeInTheDocument(); + }); + + it('supports a custom aria-label', () => { + render(); + expect( + screen.getByRole('log', { name: 'Support thread' }) + ).toBeInTheDocument(); + }); + + it('renders the jump button and scrolls to bottom on click', async () => { + const user = userEvent.setup(); + render( + + hello + + + ); + // jsdom reports zero scroll metrics, so the reader counts as at-bottom. + const jump = screen.getByTestId('jump'); + expect(jump).toHaveAttribute('tabindex', '-1'); + await user.click(jump); + }); + + it('exposes scroll commands through actionsRef', () => { + const actionsRef = createRef(); + render( + + hello + + ); + expect(actionsRef.current).not.toBeNull(); + expect(() => { + actionsRef.current?.scrollToBottom('auto'); + actionsRef.current?.scrollToMessage('m1', { behavior: 'auto' }); + actionsRef.current?.scrollToMessage('missing'); + }).not.toThrow(); + }); + + it('provides state and commands via useChatMessages', () => { + const seen: { atBottom?: boolean; visible?: string[] } = {}; + const Probe = () => { + const { atBottom, visibleMessageIds, scrollToBottom } = + useChatMessages(); + seen.atBottom = atBottom; + seen.visible = visibleMessageIds; + expect(typeof scrollToBottom).toBe('function'); + return null; + }; + render( + + + + ); + expect(seen.atBottom).toBe(true); + expect(seen.visible).toEqual([]); + }); + + it('throws when the hook is used outside Chat.Messages', () => { + const spy = vi + .spyOn(console, 'error') + .mockImplementation(() => undefined); + const Probe = () => { + useChatMessages(); + return null; + }; + expect(() => render()).toThrow( + /must be used within / + ); + spy.mockRestore(); + }); + }); + + describe('Chat root', () => { + it('renders a column container', () => { + render(content); + expect(screen.getByTestId('chat')).toHaveClass(styles.chat); + }); + }); +}); diff --git a/packages/raystack/components/chat/chat-attachment.tsx b/packages/raystack/components/chat/chat-attachment.tsx new file mode 100644 index 000000000..1165b10b9 --- /dev/null +++ b/packages/raystack/components/chat/chat-attachment.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { Cross2Icon, FileTextIcon } from '@radix-ui/react-icons'; +import { cx } from 'class-variance-authority'; +import { ComponentProps, ReactNode } from 'react'; +import { IconButton } from '../icon-button'; +import { Spinner } from '../spinner'; +import styles from './chat.module.css'; + +export type ChatAttachmentState = 'uploading' | 'error' | 'done'; + +export interface ChatAttachmentProps + extends Omit, 'title'> { + /** File name or main label. */ + title?: ReactNode; + /** Secondary line — file size, type, or the error message. */ + description?: ReactNode; + /** + * Content of the leading media square. Defaults to a file icon, or a + * spinner while `state` is `"uploading"`. + */ + media?: ReactNode; + /** + * Lifecycle of the attachment. + * @defaultValue "done" + */ + state?: ChatAttachmentState; + /** When provided, renders a remove button that calls it. */ + onRemove?: () => void; + /** + * Accessible label for the remove button. + * @defaultValue "Remove attachment" + */ + removeLabel?: string; +} + +export function ChatAttachment({ + className, + title, + description, + media, + state = 'done', + onRemove, + removeLabel = 'Remove attachment', + children, + ...props +}: ChatAttachmentProps) { + return ( +
+ + {(title || description) && ( +
+ {title && {title}} + {description && ( + + {description} + + )} +
+ )} + {children} + {onRemove && ( + + + + )} +
+ ); +} + +ChatAttachment.displayName = 'Chat.Attachment'; diff --git a/packages/raystack/components/chat/chat-context.tsx b/packages/raystack/components/chat/chat-context.tsx new file mode 100644 index 000000000..bfc86b28c --- /dev/null +++ b/packages/raystack/components/chat/chat-context.tsx @@ -0,0 +1,73 @@ +'use client'; + +import { createContext, useContext } from 'react'; + +export interface ChatMessagesState { + /** Whether the reader is at the live edge (scrolled to the bottom). */ + atBottom: boolean; + /** + * Ids of the registered messages currently intersecting the viewport, in + * document order. Only messages given a `messageId` are tracked. + */ + visibleMessageIds: string[]; +} + +export interface ChatMessagesActions { + /** Scrolls the conversation to the live edge. */ + scrollToBottom: (behavior?: ScrollBehavior) => void; + /** Scrolls the message registered with the given `messageId` into view. */ + scrollToMessage: ( + id: string, + options?: { behavior?: ScrollBehavior } + ) => void; +} + +export interface ChatMessageRegistration { + id?: string; + scrollAnchor?: boolean; +} + +export interface ChatMessagesRegistry { + register: ( + element: HTMLElement, + registration: ChatMessageRegistration + ) => () => void; +} + +export const ChatMessagesStateContext = createContext( + null +); +export const ChatMessagesActionsContext = + createContext(null); +export const ChatMessagesRegistryContext = + createContext(null); + +export function useChatMessagesState(part: string): ChatMessagesState { + const context = useContext(ChatMessagesStateContext); + if (!context) { + throw new Error(`${part} must be used within `); + } + return context; +} + +export function useChatMessagesActions(part: string): ChatMessagesActions { + const context = useContext(ChatMessagesActionsContext); + if (!context) { + throw new Error(`${part} must be used within `); + } + return context; +} + +export interface UseChatMessagesReturn + extends ChatMessagesState, + ChatMessagesActions {} + +/** + * Scroll state and commands for the enclosing `Chat.Messages`: `atBottom`, + * `visibleMessageIds`, `scrollToBottom` and `scrollToMessage`. + */ +export function useChatMessages(): UseChatMessagesReturn { + const state = useChatMessagesState('useChatMessages'); + const actions = useChatMessagesActions('useChatMessages'); + return { ...state, ...actions }; +} diff --git a/packages/raystack/components/chat/chat-item.tsx b/packages/raystack/components/chat/chat-item.tsx new file mode 100644 index 000000000..4c2cc8d1f --- /dev/null +++ b/packages/raystack/components/chat/chat-item.tsx @@ -0,0 +1,51 @@ +'use client'; + +import { useMergedRefs } from '@base-ui/utils/useMergedRefs'; +import { cx } from 'class-variance-authority'; +import { ComponentProps, useContext, useLayoutEffect, useRef } from 'react'; +import styles from './chat.module.css'; +import { ChatMessagesRegistryContext } from './chat-context'; + +export interface ChatItemProps extends ComponentProps<'div'> { + /** + * Stable id registered with the enclosing `Chat.Messages`, enabling + * visibility tracking and `scrollToMessage`. + */ + messageId?: string; + /** + * Marks the item as a scroll anchor: when it mounts inside + * `Chat.Messages`, the viewport scrolls it near the top so the reply can + * stream in below — set it on the latest user message. + * @defaultValue false + */ + scrollAnchor?: boolean; +} + +export function ChatItem({ + className, + messageId, + scrollAnchor = false, + ref, + ...props +}: ChatItemProps) { + const registry = useContext(ChatMessagesRegistryContext); + const localRef = useRef(null); + const mergedRef = useMergedRefs(localRef, ref); + + useLayoutEffect(() => { + const element = localRef.current; + if (!registry || !element) return; + return registry.register(element, { id: messageId, scrollAnchor }); + }, [registry, messageId, scrollAnchor]); + + return ( +
+ ); +} + +ChatItem.displayName = 'Chat.Item'; diff --git a/packages/raystack/components/chat/chat-messages.tsx b/packages/raystack/components/chat/chat-messages.tsx new file mode 100644 index 000000000..2113c595b --- /dev/null +++ b/packages/raystack/components/chat/chat-messages.tsx @@ -0,0 +1,459 @@ +'use client'; + +import { ScrollArea as ScrollAreaPrimitive } from '@base-ui/react/scroll-area'; +import { ArrowDownIcon } from '@radix-ui/react-icons'; +import { cx } from 'class-variance-authority'; +import { + ComponentProps, + MouseEvent, + RefObject, + useCallback, + useEffect, + useImperativeHandle, + useLayoutEffect, + useMemo, + useRef, + useState +} from 'react'; +import { ScrollAreaScrollbar } from '../scroll-area/scroll-area-scrollbar'; +import { usePrefersReducedMotion } from '../tour/use-prefers-reduced-motion'; +import styles from './chat.module.css'; +import { + ChatMessageRegistration, + ChatMessagesActions, + ChatMessagesActionsContext, + ChatMessagesRegistry, + ChatMessagesRegistryContext, + ChatMessagesState, + ChatMessagesStateContext, + useChatMessagesActions, + useChatMessagesState +} from './chat-context'; + +export interface ChatMessagesProps extends ComponentProps<'div'> { + /** + * Distance from the bottom, in pixels, within which the reader still + * counts as being at the live edge. + * @defaultValue 24 + */ + bottomThreshold?: number; + /** + * Gap kept between the viewport top and a message anchored by + * `scrollAnchor` or scrolled to with `scrollToMessage`, in pixels. + * @defaultValue 12 + */ + anchorOffset?: number; + /** + * Whether to follow new content while the reader is at the live edge. + * @defaultValue true + */ + autoScroll?: boolean; + /** A ref populated with the imperative scroll commands. */ + actionsRef?: RefObject; + /** + * Accessible label for the message log. + * @defaultValue "Conversation" + */ + 'aria-label'?: string; +} + +interface PendingAnchor { + element: HTMLElement; +} + +export function ChatMessages({ + className, + children, + bottomThreshold = 24, + anchorOffset = 12, + autoScroll = true, + actionsRef, + 'aria-label': ariaLabel = 'Conversation', + ...props +}: ChatMessagesProps) { + const viewportRef = useRef(null); + const contentRef = useRef(null); + + const [atBottom, setAtBottomState] = useState(true); + const atBottomRef = useRef(true); + const followingRef = useRef(true); + const autoScrollRef = useRef(autoScroll); + autoScrollRef.current = autoScroll; + const bottomThresholdRef = useRef(bottomThreshold); + bottomThresholdRef.current = bottomThreshold; + const anchorOffsetRef = useRef(anchorOffset); + anchorOffsetRef.current = anchorOffset; + + const [visibleMessageIds, setVisibleMessageIds] = useState([]); + const [spacerHeight, setSpacerHeight] = useState(0); + const spacerHeightRef = useRef(0); + const anchorShrinkRef = useRef<{ + rawAtAnchor: number; + spacerAtAnchor: number; + } | null>(null); + + const registryMapRef = useRef(new Map()); + const visibleSetRef = useRef(new Set()); + const intersectionObserverRef = useRef(null); + + const mountedRef = useRef(false); + const pendingAnchorRef = useRef(null); + const [anchorTick, setAnchorTick] = useState(0); + const prevFirstRef = useRef<{ element: Element; top: number } | null>(null); + const scrollingToBottomRef = useRef(false); + + const reducedMotion = usePrefersReducedMotion(); + const reducedMotionRef = useRef(reducedMotion); + reducedMotionRef.current = reducedMotion; + + const setAtBottom = useCallback((next: boolean) => { + if (atBottomRef.current === next) return; + atBottomRef.current = next; + setAtBottomState(next); + }, []); + + const isAtBottom = useCallback(() => { + const viewport = viewportRef.current; + if (!viewport) return true; + return ( + viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight <= + bottomThresholdRef.current + ); + }, []); + + /** Offset of an element within the viewport's scroll coordinate space. */ + const offsetWithin = useCallback((element: Element) => { + const viewport = viewportRef.current; + if (!viewport) return 0; + return ( + element.getBoundingClientRect().top - + viewport.getBoundingClientRect().top + + viewport.scrollTop + ); + }, []); + + const setSpacer = useCallback((next: number) => { + if (spacerHeightRef.current === next) return; + spacerHeightRef.current = next; + setSpacerHeight(next); + }, []); + + const scrollViewportTo = useCallback( + (top: number, behavior: ScrollBehavior) => { + const viewport = viewportRef.current; + if (!viewport) return; + if (typeof viewport.scrollTo === 'function') { + viewport.scrollTo({ top, behavior }); + } else { + viewport.scrollTop = top; + } + }, + [] + ); + + const scrollToBottom = useCallback( + (behavior: ScrollBehavior = 'smooth') => { + const viewport = viewportRef.current; + if (!viewport) return; + followingRef.current = true; + const resolved: ScrollBehavior = reducedMotionRef.current + ? 'auto' + : behavior; + if (resolved === 'smooth') scrollingToBottomRef.current = true; + scrollViewportTo(viewport.scrollHeight, resolved); + setAtBottom(true); + }, + [scrollViewportTo, setAtBottom] + ); + + const scrollToMessage = useCallback( + (id: string, options?: { behavior?: ScrollBehavior }) => { + const viewport = viewportRef.current; + const element = registryMapRef.current.get(id); + if (!viewport || !element) return; + followingRef.current = false; + scrollViewportTo( + Math.max(0, offsetWithin(element) - anchorOffsetRef.current), + reducedMotionRef.current ? 'auto' : (options?.behavior ?? 'smooth') + ); + }, + [offsetWithin, scrollViewportTo] + ); + + const updateVisibleIds = useCallback(() => { + const registry = registryMapRef.current; + const ids = Array.from(visibleSetRef.current); + ids.sort((a, b) => { + const elementA = registry.get(a); + const elementB = registry.get(b); + if (!elementA || !elementB) return 0; + return elementA.compareDocumentPosition(elementB) & + Node.DOCUMENT_POSITION_FOLLOWING + ? -1 + : 1; + }); + setVisibleMessageIds(previous => + previous.length === ids.length && + previous.every((id, index) => id === ids[index]) + ? previous + : ids + ); + }, []); + + const register = useCallback( + (element: HTMLElement, registration: ChatMessageRegistration) => { + const { id, scrollAnchor } = registration; + if (id) { + registryMapRef.current.set(id, element); + intersectionObserverRef.current?.observe(element); + } + if (scrollAnchor && mountedRef.current) { + // Anchoring pauses following; the reply streams in below while the + // anchored message holds near the viewport top. + followingRef.current = false; + pendingAnchorRef.current = { element }; + const viewport = viewportRef.current; + if (viewport) { + const contentEnd = viewport.scrollHeight - spacerHeightRef.current; + const below = contentEnd - offsetWithin(element); + const needed = Math.max( + 0, + Math.round(viewport.clientHeight - anchorOffsetRef.current - below) + ); + anchorShrinkRef.current = { + rawAtAnchor: contentEnd, + spacerAtAnchor: needed + }; + setSpacer(needed); + } + setAnchorTick(tick => tick + 1); + } + return () => { + if (id) { + if (registryMapRef.current.get(id) === element) { + registryMapRef.current.delete(id); + } + intersectionObserverRef.current?.unobserve(element); + if (visibleSetRef.current.delete(id)) updateVisibleIds(); + } + }; + }, + [offsetWithin, setSpacer, updateVisibleIds] + ); + + // Perform the pending anchor scroll after the spacer has been committed, + // so the target position exists before the frame paints. + // biome-ignore lint/correctness/useExhaustiveDependencies: keyed on anchorTick — each anchor request bumps it. + useLayoutEffect(() => { + const pending = pendingAnchorRef.current; + const viewport = viewportRef.current; + if (!pending || !viewport) return; + pendingAnchorRef.current = null; + if (!pending.element.isConnected) return; + viewport.scrollTop = Math.max( + 0, + offsetWithin(pending.element) - anchorOffsetRef.current + ); + setAtBottom(isAtBottom()); + }, [anchorTick, offsetWithin, isAtBottom, setAtBottom]); + + // Start pinned to the live edge. + useLayoutEffect(() => { + const viewport = viewportRef.current; + if (viewport) viewport.scrollTop = viewport.scrollHeight; + mountedRef.current = true; + }, []); + + // Keep the reading position stable when history is prepended above. + useLayoutEffect(() => { + const viewport = viewportRef.current; + const content = contentRef.current; + if (!viewport || !content) return; + let first = content.firstElementChild; + while (first && first.hasAttribute('data-chat-jump-button')) { + first = first.nextElementSibling; + } + const previous = prevFirstRef.current; + if ( + previous && + previous.element.isConnected && + first !== previous.element && + !atBottomRef.current + ) { + const delta = offsetWithin(previous.element) - previous.top; + if (delta > 0) viewport.scrollTop += delta; + } + prevFirstRef.current = first + ? { element: first, top: offsetWithin(first) } + : null; + }); + + // Scroll tracking: keep atBottom fresh and treat user scrolls as intent to + // follow (at bottom) or stop following (scrolled up). + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport) return; + const handleScroll = () => { + const bottom = isAtBottom(); + setAtBottom(bottom); + if (scrollingToBottomRef.current) { + if (bottom) scrollingToBottomRef.current = false; + return; + } + followingRef.current = bottom; + }; + viewport.addEventListener('scroll', handleScroll, { passive: true }); + return () => viewport.removeEventListener('scroll', handleScroll); + }, [isAtBottom, setAtBottom]); + + // Follow growth while at the live edge; consume the anchor spacer as the + // reply streams into it so the blank space fills up instead of lingering. + useEffect(() => { + const viewport = viewportRef.current; + const content = contentRef.current; + if (!viewport || !content || typeof ResizeObserver === 'undefined') return; + const resizeObserver = new ResizeObserver(() => { + const shrink = anchorShrinkRef.current; + if (shrink) { + const raw = content.offsetHeight - spacerHeightRef.current; + const next = Math.max( + 0, + shrink.spacerAtAnchor - (raw - shrink.rawAtAnchor) + ); + setSpacer(next); + if (next === 0) anchorShrinkRef.current = null; + } + if (autoScrollRef.current && followingRef.current) { + viewport.scrollTop = viewport.scrollHeight; + setAtBottom(true); + } else { + setAtBottom(isAtBottom()); + } + }); + resizeObserver.observe(content); + resizeObserver.observe(viewport); + return () => resizeObserver.disconnect(); + }, [isAtBottom, setAtBottom, setSpacer]); + + // Track which registered messages intersect the viewport. + useEffect(() => { + const viewport = viewportRef.current; + if (!viewport || typeof IntersectionObserver === 'undefined') return; + const idOf = (element: Element) => + element.getAttribute('data-message-id') ?? undefined; + const intersectionObserver = new IntersectionObserver( + entries => { + for (const entry of entries) { + const id = idOf(entry.target); + if (!id) continue; + if (entry.isIntersecting) visibleSetRef.current.add(id); + else visibleSetRef.current.delete(id); + } + updateVisibleIds(); + }, + { root: viewport, threshold: 0 } + ); + intersectionObserverRef.current = intersectionObserver; + for (const element of registryMapRef.current.values()) { + intersectionObserver.observe(element); + } + return () => { + intersectionObserver.disconnect(); + intersectionObserverRef.current = null; + }; + }, [updateVisibleIds]); + + const actions = useMemo( + () => ({ scrollToBottom, scrollToMessage }), + [scrollToBottom, scrollToMessage] + ); + + useImperativeHandle(actionsRef, () => actions, [actions]); + + const state = useMemo( + () => ({ atBottom, visibleMessageIds }), + [atBottom, visibleMessageIds] + ); + + const registry = useMemo( + () => ({ register }), + [register] + ); + + return ( + + + + + + + {children} + {spacerHeight > 0 && ( +