Skip to content

feat(react-ui-base): add thread-history compound component#2268

Closed
lachieh wants to merge 3 commits intolachieh/tam-1057-message-suggestionsfrom
lachieh/tam-1064-thread-history
Closed

feat(react-ui-base): add thread-history compound component#2268
lachieh wants to merge 3 commits intolachieh/tam-1057-message-suggestionsfrom
lachieh/tam-1064-thread-history

Conversation

@lachieh
Copy link
Copy Markdown
Contributor

@lachieh lachieh commented Feb 7, 2026

Summary

Adds thread-history compound component base primitives and styled wrapper.

Base: Root, Header, List, Item, CollapseToggle, NewThreadButton, SearchInput
Styled: Composes base components with Tailwind styling

Fixes TAM-1064

Test Plan

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

🤖 Generated with Claude Code

@charliecreates charliecreates Bot requested a review from CharlieHelps February 7, 2026 00:11
@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

@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 new base primitives are generally well-structured, but several components (CollapseToggle, NewThreadButton, Item) allow consumer onClick props to override core internal behavior due to {...props} spread order, which is a real correctness/compatibility risk. ThreadHistoryRoot introduces a global CSS variable side effect without cleanup, which can leak state across mounts. The styled wrapper’s collapsed behavior likely changed subtly because it no longer applies the prior collapse transition classes and instead returns null in render, potentially affecting layout/scroll behavior.

Additional notes (1)
  • Readability | packages/ui-registry/src/components/thread-history/thread-history.tsx:271-289
    In the styled wrapper, ThreadHistoryBase.List is given a className but the render function returns null when collapsed. This is fine, but note that the base List still sets data-* attributes and can be styled via [data-collapsed]. The wrapper also removed the previous collapsed transition classes; now the parent will always have overflow-y-auto flex-1 ... even when collapsed.

This can cause layout/scroll affordances to differ from the old behavior (e.g., still reserving space / scroll container present).

Summary of changes

What changed

✨ New ThreadHistory compound component in react-ui-base

  • Added a new public export path @tambo-ai/react-ui-base/thread-history via packages/react-ui-base/package.json exports.
  • Exposed ThreadHistory namespace and related prop/render-prop types from packages/react-ui-base/src/index.ts.
  • Introduced base primitives under packages/react-ui-base/src/thread-history/*:
    • Root + context (ThreadHistoryRootContext)
    • Header, List, Item
    • CollapseToggle, NewThreadButton, SearchInput
  • Implemented shared state in ThreadHistoryRoot using useTamboThreadList() / useTamboThread() and provided it via context.

🎨 Refactor UI registry component to compose base primitives

  • Updated packages/ui-registry/src/components/thread-history/thread-history.tsx to:
    • Remove the local context + thread filtering logic
    • Compose UI using ThreadHistoryBase.* components and render props
    • Keep styling and thread rename/generate interactions in the styled wrapper

Comment on lines +29 to +37
return (
<Comp
ref={ref}
data-slot="thread-history-collapse-toggle"
data-collapsed={isCollapsed || undefined}
data-position={position}
onClick={handleToggle}
aria-label={isCollapsed ? "Expand sidebar" : "Collapse sidebar"}
{...props}
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.

ThreadHistoryCollapseToggle hard-sets onClick={handleToggle} and then spreads {...props} afterwards. That means any consumer-provided onClick overrides the internal toggle, potentially breaking the component silently.

This is a correctness issue for a base primitive: it should always toggle, while still allowing consumers to run their own handler.

Suggestion

Change the click handler to compose with props.onClick instead of being overwritten (or overriding the consumer). For example:

const handleClick: React.MouseEventHandler<HTMLButtonElement> = (e) => {
  props.onClick?.(e);
  if (e.defaultPrevented) return;
  setIsCollapsed((prev) => !prev);
};

return (
  <Comp
    ...
    onClick={handleClick}
    {...props}
  />
);

This keeps the primitive reliable while preserving extensibility. Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

Comment on lines +70 to +78
return (
<Comp
ref={ref}
data-slot="thread-history-new-thread-button"
data-collapsed={isCollapsed || undefined}
onClick={handleNewThread}
title="New thread"
{...props}
>
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.

ThreadHistoryNewThreadButton has the same handler precedence issue: onClick={handleNewThread} is set but then {...props} comes after, allowing a consumer onClick to override and prevent new thread creation.

Also, handleNewThread stops propagation only when invoked with a mouse event. If consumers call it via their own click handler (or if composed later), behavior may differ.

Suggestion

Compose props.onClick with the internal action and ensure internal behavior still runs unless prevented:

const handleClick: React.MouseEventHandler<HTMLButtonElement> = async (e) => {
  props.onClick?.(e);
  if (e.defaultPrevented) return;
  e.stopPropagation();
  await handleNewThread();
};

return (
  <Comp
    ...
    onClick={handleClick}
    {...props}
  />
);

(You can inline handleNewThread and remove the optional event param.) Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

Comment on lines +70 to +78
return (
<Comp
ref={ref}
data-slot="thread-history-new-thread-button"
data-collapsed={isCollapsed || undefined}
onClick={handleNewThread}
title="New thread"
{...props}
>
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.

ThreadHistoryNewThreadButton also allows onClick to override the internal handler

You pass onClick={handleNewThread} and then spread {...props} after, which means consumer onClick will override and prevent new thread creation.

Additionally, the global document.addEventListener('keydown', ...) means every mounted instance registers a hotkey and it will fire even when the user is typing in an input/textarea or when focus is inside a menu. That’s likely to cause surprising behavior.

Suggestion
  1. Compose onClick like in the other primitives.

  2. Gate the keyboard shortcut to avoid triggering while typing, and consider requiring focus within the ThreadHistory root.

React.useEffect(() => {
  const handleKeyDown = (event: KeyboardEvent) => {
    const el = event.target as HTMLElement | null;
    const isTypingTarget =
      el?.tagName === "INPUT" ||
      el?.tagName === "TEXTAREA" ||
      (el as HTMLElement | null)?.isContentEditable;

    if (isTypingTarget) return;

    if (event.altKey && event.shiftKey && event.key.toLowerCase() === "n") {
      event.preventDefault();
      void handleNewThread();
    }
  };

  document.addEventListener("keydown", handleKeyDown);
  return () => document.removeEventListener("keydown", handleKeyDown);
}, [handleNewThread]);

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

Comment on lines +57 to +65
return (
<Comp
ref={ref}
data-slot="thread-history-item"
data-active={isActive || undefined}
data-thread-id={thread.id}
onClick={handleClick}
{...props}
>
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.

ThreadHistoryItem also risks breaking its core behavior because {...props} comes after onClick={handleClick}. A consumer-provided onClick would replace the internal switchCurrentThread call.

Additionally, if you do compose handlers, make sure to pass the click event through so consumers can preventDefault() / stopPropagation() when needed.

Suggestion

Compose the click handler instead of allowing it to be overridden:

const handleItemClick: React.MouseEventHandler<HTMLDivElement> = (e) => {
  props.onClick?.(e);
  if (e.defaultPrevented) return;
  switchCurrentThread(thread.id);
  onThreadChange?.();
};

return (
  <Comp
    ...
    onClick={handleItemClick}
    {...props}
  />
);

Also consider using a semantic element (e.g. button) when not asChild so the item is keyboard accessible by default. Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

Comment on lines +70 to +74
// Update CSS variable when sidebar collapses/expands
React.useEffect(() => {
const sidebarWidth = isCollapsed ? "3rem" : "16rem";
document.documentElement.style.setProperty("--sidebar-width", sidebarWidth);
}, [isCollapsed]);
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.

ThreadHistoryRoot mutates a global CSS variable on document.documentElement but never restores the previous value on unmount. If multiple sidebars mount/unmount (or different pages set the same variable), this can leave stale global state behind.

Since react-ui-base is a primitives package, global side effects should be carefully scoped and cleaned up.

Suggestion

Capture the previous value and restore it in the effect cleanup:

React.useEffect(() => {
  const el = document.documentElement;
  const prev = el.style.getPropertyValue("--sidebar-width");
  el.style.setProperty("--sidebar-width", isCollapsed ? "3rem" : "16rem");
  return () => {
    if (prev) el.style.setProperty("--sidebar-width", prev);
    else el.style.removeProperty("--sidebar-width");
  };
}, [isCollapsed]);

Alternatively, consider moving this responsibility to the styled wrapper (ui-registry) rather than the base primitive. Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this suggestion.

Comment on lines +46 to +68
<ThreadHistoryBase.Root
ref={ref}
onThreadChange={onThreadChange}
defaultCollapsed={defaultCollapsed}
position={position}
className={cn(
"border-flat bg-container h-full transition-all duration-300 flex-none",
position === "left" ? "border-r" : "border-l",
className,
)}
{...props}
>
<div
ref={ref}
className={cn(
"border-flat bg-container h-full transition-all duration-300 flex-none",
position === "left" ? "border-r" : "border-l",
isCollapsed ? "w-12" : "w-64",
className,
)}
{...props}
>
{({ isCollapsed }) => (
<div
className={cn(
"flex flex-col h-full",
isCollapsed ? "py-4 px-2" : "p-4",
)} // py-4 px-2 is for better alignment when isCollapsed
)}
>
{children}
</div>
</div>
</ThreadHistoryContext.Provider>
)}
</ThreadHistoryBase.Root>
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.

Styled wrapper dropped explicit width handling

Previously the styled wrapper applied isCollapsed ? "w-12" : "w-64". After refactor, the width class is no longer applied in the wrapper; instead, the base root sets a global --sidebar-width variable. Unless the layout CSS elsewhere actually uses var(--sidebar-width), this change can break the visual collapse/expand behavior.

Even if it works today, it makes the wrapper’s sizing implicit and harder to reason about.

Suggestion

Keep width behavior local to the styled wrapper so it’s explicit and doesn’t rely on global CSS variables:

<ThreadHistoryBase.Root ...>
  {({ isCollapsed }) => (
    <div
      className={cn(
        "border-flat bg-container h-full transition-all duration-300 flex-none",
        position === "left" ? "border-r" : "border-l",
        isCollapsed ? "w-12" : "w-64",
        className,
      )}
    >
      ...
    </div>
  )}
</ThreadHistoryBase.Root>

(or ensure the component actually consumes --sidebar-width in its styles).

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

Comment on lines +47 to +54
const expandOnSearch = React.useCallback(() => {
if (isCollapsed) {
setIsCollapsed(false);
setTimeout(() => {
searchInputRef.current?.focus();
}, 300);
}
}, [isCollapsed, setIsCollapsed]);
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.

ThreadHistorySearchInput uses a fixed setTimeout(300) to focus, which is brittle

The focus logic assumes an animation duration and schedules focus 300ms later. That can easily desync if styles change, and can lead to focusing an element that has been unmounted.

Also, there’s no cancellation/cleanup of the timeout on unmount or on rapid toggles.

Suggestion

Prefer focusing on the next frame(s) and clean up pending timers. If you want to wait for expansion, use requestAnimationFrame twice or a timeout you cancel.

const focusTimerRef = React.useRef<number | null>(null);

React.useEffect(() => {
  return () => {
    if (focusTimerRef.current != null) {
      window.clearTimeout(focusTimerRef.current);
    }
  };
}, []);

const expandOnSearch = React.useCallback(() => {
  if (!isCollapsed) {
    searchInputRef.current?.focus();
    return;
  }

  setIsCollapsed(false);
  if (focusTimerRef.current != null) window.clearTimeout(focusTimerRef.current);
  focusTimerRef.current = window.setTimeout(() => {
    searchInputRef.current?.focus();
  }, 0);
}, [isCollapsed, setIsCollapsed]);

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

@charliecreates charliecreates Bot removed the request for review from CharlieHelps February 7, 2026 00:14
@lachieh lachieh force-pushed the lachieh/tam-1057-message-suggestions branch from 3d02498 to b753c1c Compare February 9, 2026 01:07
@lachieh lachieh force-pushed the lachieh/tam-1064-thread-history branch from 7467ef3 to 5a90cd3 Compare February 9, 2026 22:04
@github-actions github-actions Bot added area: api Changes to the API (apps/api) area: web Changes to the web app (apps/web) area: cli Changes to the CLI package area: core Changes to the core package (packages/core) area: backend Changes to the backend package (packages/backend) area: react-sdk Changes to the React SDK area: config Changes to repository configuration files area: documentation Improvements or additions to documentation area: github actions Changes to GitHub Actions workflows labels Feb 9, 2026
@lachieh lachieh force-pushed the lachieh/tam-1057-message-suggestions branch from b753c1c to c07cab3 Compare February 9, 2026 22:06
@lachieh lachieh force-pushed the lachieh/tam-1064-thread-history branch from 5a90cd3 to 8fac220 Compare February 9, 2026 22:16
@github-actions github-actions Bot removed area: documentation Improvements or additions to documentation area: github actions Changes to GitHub Actions workflows area: react-sdk Changes to the React SDK area: api Changes to the API (apps/api) area: cli Changes to the CLI package area: web Changes to the web app (apps/web) area: core Changes to the core package (packages/core) area: backend Changes to the backend package (packages/backend) area: config Changes to repository configuration files labels Feb 9, 2026
@lachieh lachieh closed this Feb 20, 2026
@lachieh lachieh deleted the lachieh/tam-1064-thread-history branch February 27, 2026 19:47
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