Skip to content

feat(react-ui-base): add scrollable-message-container compound component#2276

Closed
lachieh wants to merge 2 commits intolachieh/tam-1057-message-suggestionsfrom
lachieh/tam-1061-scrollable-message-container
Closed

feat(react-ui-base): add scrollable-message-container compound component#2276
lachieh wants to merge 2 commits intolachieh/tam-1057-message-suggestionsfrom
lachieh/tam-1061-scrollable-message-container

Conversation

@lachieh
Copy link
Copy Markdown
Contributor

@lachieh lachieh commented Feb 7, 2026

Summary

Adds scrollable-message-container compound component base primitives and styled wrapper.

Base: Root, Viewport, ScrollToBottom
Styled: Composes base components with Tailwind styling

Fixes TAM-1061

Test Plan

  • Verify component renders in showcase
  • Run npm run lint && npm run check-types && npm test

🤖 Generated with Claude Code

@vercel
Copy link
Copy Markdown

vercel Bot commented Feb 7, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cloud Error Error Feb 9, 2026 10:18pm
showcase Error Error Feb 9, 2026 10:18pm
tambo-docs Error Error Feb 9, 2026 10:18pm

@charliecreates charliecreates Bot requested a review from CharlieHelps February 7, 2026 00:12
@github-actions github-actions Bot added area: ui area: react-ui-base Changes to the react-ui-base package (packages/react-ui-base) status: in progress Work is currently being done contributor: tambo-team Created by a Tambo team member change: feat New feature labels Feb 7, 2026
Copy link
Copy Markdown
Contributor

@charliecreates charliecreates Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The biggest issue is architectural: ScrollableMessageContainerRoot lives in react-ui-base but directly depends on @tambo-ai/react thread state, making the “base primitive” not actually generic and likely to throw if used outside a Tambo provider. There are also performance/UX concerns around always using behavior: "smooth" for auto-scrolling (especially during streaming). Lastly, the context ref typing is inconsistent with how it’s mutated, and the styled wrapper’s props/asChild forwarding is redundant/surprising.

Additional notes (1)
  • Readability | ...nents/scrollable-message-container/scrollable-message-container.tsx:11-11
    The wrapper component’s props type intersects ScrollableMessageContainerViewportProps with React.HTMLAttributes<HTMLDivElement>. Since ScrollableMessageContainerViewportProps is already BaseProps<React.HTMLAttributes<HTMLDivElement>>, this intersection is redundant and can confuse consumers (and can lead to harder-to-read generated docs).

Also, in the wrapper, {...props} is spread onto Viewport while Root is rendered as asChild with no props passed—so any asChild passed to the wrapper will be forwarded to Viewport, not Root. If the wrapper is meant to preserve the original single-element API, it’s fine to not expose asChild at all, but currently it’s implicitly exposed and does something surprising.

Summary of changes

Added ScrollableMessageContainer compound component (react-ui-base)

  • Exported a new package entrypoint ./scrollable-message-container in packages/react-ui-base/package.json to publish ESM/CJS builds (dist/esm/..., dist/cjs/...).
  • Re-exported ScrollableMessageContainer and its related types from packages/react-ui-base/src/index.ts.
  • Introduced a new compound component under packages/react-ui-base/src/scrollable-message-container/:
    • Root primitive that manages scroll state, auto-scroll behavior, and provides context.
    • Viewport primitive that binds the scrollable element to the root’s viewportRef.
    • ScrollToBottom primitive that exposes render props (isAtBottom, scrollToBottom).

Updated ui-registry to consume the base primitives

  • Updated packages/ui-registry/src/components/scrollable-message-container/config.json to depend on @tambo-ai/react-ui-base@next and to include the base registry files.
  • Refactored the styled ScrollableMessageContainer wrapper to compose ScrollableMessageContainerBase.Root + Viewport, preserving the previous single-component API surface while moving behavior into the base package.

Comment on lines +1 to +46
import { Slot } from "@radix-ui/react-slot";
import { GenerationStage, useTambo } from "@tambo-ai/react";
import * as React from "react";
import { BaseProps } from "../../types/component-render-or-children";
import { ScrollableMessageContainerRootContext } from "./scrollable-message-container-root-context";

export type ScrollableMessageContainerRootProps = BaseProps<
React.HTMLAttributes<HTMLDivElement>
>;

/**
* Root primitive for the scrollable message container component.
* Manages auto-scroll state, scroll position tracking, and thread message observation.
* Provides context for child components (Viewport, ScrollToBottom).
* @returns The root scrollable message container element with context provider
*/
export const ScrollableMessageContainerRoot = React.forwardRef<
HTMLDivElement,
ScrollableMessageContainerRootProps
>(function ScrollableMessageContainerRoot(
{ children, asChild, ...props },
ref,
) {
const viewportRef = React.useRef<HTMLDivElement>(null);
const { thread } = useTambo();
const [shouldAutoscroll, setShouldAutoscroll] = React.useState(true);
const [isAtBottom, setIsAtBottom] = React.useState(true);
const lastScrollTopRef = React.useRef(0);

const messagesContent = React.useMemo(() => {
if (!thread.messages) return null;

return thread.messages.map((message) => ({
id: message.id,
content: message.content,
tool_calls: message.tool_calls,
component: message.component,
reasoning: message.reasoning,
componentState: message.componentState,
}));
}, [thread.messages]);

const generationStage = React.useMemo(
() => thread?.generationStage ?? GenerationStage.IDLE,
[thread?.generationStage],
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ScrollableMessageContainerRoot is part of react-ui-base, but it directly imports and depends on @tambo-ai/react (useTambo, GenerationStage). That makes this “base primitive” tightly coupled to the Tambo thread model and effectively not reusable outside that context.

This is a design/correctness issue for a base component library: consumers who want a generic scrollable container will be forced to install and run Tambo thread logic, and the component becomes harder to test in isolation. It also creates an implicit contract that the Root must be used inside a TamboProvider, otherwise useTambo() will likely throw at runtime.

If the intent is truly “message container for Tambo threads”, consider moving it to a Tambo-specific package (or to ui-registry only). If you want it in react-ui-base, the Root should accept the relevant signals as props (e.g., contentKey, isStreaming, onReachBottom, etc.) rather than reaching into Tambo state itself.

Suggestion

Decouple react-ui-base from @tambo-ai/react by lifting “what changed” and “streaming vs not” into props, and remove the useTambo() dependency.

For example:

export type ScrollableMessageContainerRootProps = BaseProps<
  React.HTMLAttributes<HTMLDivElement> & {
    /** Changes when content changes; used to trigger autoscroll */
    contentKey?: unknown;
    /** Whether content is currently streaming (affects scroll scheduling) */
    isStreaming?: boolean;
  }
>;

Then use contentKey in the autoscroll effect deps instead of messagesContent, and use isStreaming instead of GenerationStage.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit implementing this refactor (including updating the ui-registry wrapper to pass the appropriate values from useTambo()).

Comment on lines +48 to +120
const scrollToBottom = React.useCallback(() => {
if (!viewportRef.current) return;

viewportRef.current.scrollTo({
top: viewportRef.current.scrollHeight,
behavior: "smooth",
});
}, []);

const suspendAutoscroll = React.useCallback(() => {
setShouldAutoscroll(false);
}, []);

const resumeAutoscroll = React.useCallback(() => {
setShouldAutoscroll(true);
}, []);

/**
* Handle scroll events from the viewport to detect user scrolling direction
* and update isAtBottom state.
*/
const handleViewportScroll = React.useCallback(() => {
if (!viewportRef.current) return;

const { scrollTop, scrollHeight, clientHeight } = viewportRef.current;
const atBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 8;

setIsAtBottom(atBottom);

if (scrollTop < lastScrollTopRef.current) {
setShouldAutoscroll(false);
} else if (atBottom) {
setShouldAutoscroll(true);
}

lastScrollTopRef.current = scrollTop;
}, []);

/**
* Attach scroll listener to the viewport element.
*/
React.useEffect(() => {
const viewport = viewportRef.current;
if (!viewport) return;

viewport.addEventListener("scroll", handleViewportScroll);
return () => {
viewport.removeEventListener("scroll", handleViewportScroll);
};
}, [handleViewportScroll]);

/**
* Auto-scroll to bottom when message content changes and autoscroll is enabled.
*/
React.useEffect(() => {
if (viewportRef.current && messagesContent && shouldAutoscroll) {
const scroll = () => {
if (viewportRef.current) {
viewportRef.current.scrollTo({
top: viewportRef.current.scrollHeight,
behavior: "smooth",
});
}
};

if (generationStage === GenerationStage.STREAMING_RESPONSE) {
requestAnimationFrame(scroll);
} else {
const timeoutId = setTimeout(scroll, 50);
return () => clearTimeout(timeoutId);
}
}
}, [messagesContent, generationStage, shouldAutoscroll]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

scrollToBottom() always uses behavior: "smooth". When called during streaming updates and/or in the auto-scroll effect, this can cause jank (repeated smooth scroll animations) and can also fight user intent if they briefly scroll.

You already differentiate streaming vs non-streaming for scheduling; the same distinction should usually apply to scroll behavior (instant during streaming, smooth for user-initiated button clicks).

Suggestion

Add an option/parameter to scrollToBottom (or a separate scrollToBottomInstant) and use instant scrolling for auto-scroll while keeping smooth scrolling for explicit user actions.

Example:

const scrollToBottom = React.useCallback((opts?: { behavior?: ScrollBehavior }) => {
  const el = viewportRef.current;
  if (!el) return;
  el.scrollTo({ top: el.scrollHeight, behavior: opts?.behavior ?? "auto" });
}, []);
  • In the auto-scroll effect: scrollToBottom({ behavior: "auto" })
  • In ScrollToBottom button: scrollToBottom({ behavior: "smooth" })

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this change.

Comment on lines +65 to +84
/**
* Handle scroll events from the viewport to detect user scrolling direction
* and update isAtBottom state.
*/
const handleViewportScroll = React.useCallback(() => {
if (!viewportRef.current) return;

const { scrollTop, scrollHeight, clientHeight } = viewportRef.current;
const atBottom = Math.abs(scrollHeight - scrollTop - clientHeight) < 8;

setIsAtBottom(atBottom);

if (scrollTop < lastScrollTopRef.current) {
setShouldAutoscroll(false);
} else if (atBottom) {
setShouldAutoscroll(true);
}

lastScrollTopRef.current = scrollTop;
}, []);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleViewportScroll toggles shouldAutoscroll based on scroll direction using lastScrollTopRef. This can disable autoscroll even when the user scrolls only slightly (e.g., trackpad jitter) and doesn’t consider programmatic smooth scrolling (triggered by scrollToBottom) which also fires scroll events and can flip state unexpectedly.

It’s safer to drive autoscroll primarily from atBottom (with tolerance), and only suspend on explicit user intent (wheel/touch/scrollbar drag) or when atBottom becomes false.

Suggestion

Simplify the state machine:

  • Compute atBottom and always set isAtBottom.
  • Set shouldAutoscroll to atBottom (or only resume when atBottom is true), and suspend only when atBottom becomes false and the event was user-initiated.

A pragmatic improvement without complex pointer heuristics:

const atBottom = ...;
setIsAtBottom(atBottom);
setShouldAutoscroll(atBottom);

If you need “suspend even when near-bottom but user scrolls up a bit”, add onWheel/onTouchMove handlers on the viewport to call suspendAutoscroll().

Reply with "@CharlieHelps yes please" if you'd like me to add a commit that adjusts the autoscroll logic and reduces unexpected toggling.

Comment on lines +6 to +19
export interface ScrollableMessageContainerRootContextValue {
/** Whether the container should auto-scroll to the bottom when content changes. */
shouldAutoscroll: boolean;
/** Whether the scroll position is currently at the bottom of the container. */
isAtBottom: boolean;
/** Scroll the container to the bottom. */
scrollToBottom: () => void;
/** Suspend auto-scrolling (e.g., when user scrolls up). */
suspendAutoscroll: () => void;
/** Resume auto-scrolling (e.g., when user scrolls to bottom). */
resumeAutoscroll: () => void;
/** Ref for the scrollable viewport element. */
viewportRef: React.RefObject<HTMLDivElement | null>;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Viewport, the context ref is typed as RefObject<HTMLDivElement | null> but you’re mutating it by casting to MutableRefObject. This works because it’s actually created via useRef in the root, but the current context typing communicates “read-only” while the viewport relies on mutability.

This mismatch makes the API brittle: if the ref implementation ever changes, this becomes a footgun. It also encourages unsafe casts in downstream primitives.

Suggestion

Change the context type to accurately reflect intended usage by storing a React.MutableRefObject<HTMLDivElement | null>.

export interface ScrollableMessageContainerRootContextValue {
  // ...
  viewportRef: React.MutableRefObject<HTMLDivElement | null>;
}

Then remove the cast in Viewport and assign directly.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit updating the context typing + viewport assignment.

Comment on lines +27 to +40
return (
<Comp
ref={(node: HTMLDivElement | null) => {
// Assign to the context ref
(viewportRef as React.MutableRefObject<HTMLDivElement | null>).current =
node;

// Forward the external ref
if (typeof ref === "function") {
ref(node);
} else if (ref) {
ref.current = node;
}
}}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The Viewport assigns the context ref by casting viewportRef to MutableRefObject and manually merges forwarded refs. This is brittle and easy to get wrong (especially if viewportRef type changes), and it’s a pattern that tends to get duplicated.

Prefer a small composeRefs/mergeRefs utility or useCallback ref that sets both without type assertions.

Suggestion

Replace the cast + manual branching with a composeRefs helper.

Example:

function composeRefs<T>(...refs: Array<React.Ref<T> | undefined>) {
  return (node: T) => {
    for (const ref of refs) {
      if (!ref) continue;
      if (typeof ref === "function") ref(node);
      else (ref as React.MutableRefObject<T>).current = node;
    }
  };
}

Then:

ref={composeRefs(ref, viewportRef)}

This removes the unsafe assertion and simplifies the component.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this ref composition change.

Comment on lines +41 to +55
const { content, componentProps } = useRender(props, {
isAtBottom,
scrollToBottom,
});

return (
<Comp
ref={ref}
data-slot="scrollable-message-container-scroll-to-bottom"
data-at-bottom={isAtBottom || undefined}
{...componentProps}
>
{content}
</Comp>
);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ScrollableMessageContainerScrollToBottom always renders a wrapping div/Slot even when there is no children and no render function output. The docstring says it can return null, but the implementation doesn’t.

Rendering an empty element can affect layout, focus order, and CSS selectors (e.g. spacing utilities).

Suggestion

Return null when there’s nothing to render.

After useRender, do:

if (content == null) return null;

(Use whatever “empty” semantics your useRender contract defines.)

Reply with "@CharlieHelps yes please" if you'd like me to add a commit updating this component to truly return null when there’s no content.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: react-ui-base Changes to the react-ui-base package (packages/react-ui-base) area: ui change: feat New feature contributor: tambo-team Created by a Tambo team member status: in progress Work is currently being done

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant