Skip to content

feat(react-ui-base): add thread-dropdown compound component#2278

Closed
lachieh wants to merge 3 commits intolachieh/tam-1057-message-suggestionsfrom
lachieh/tam-1063-thread-dropdown
Closed

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

Conversation

@lachieh
Copy link
Copy Markdown
Contributor

@lachieh lachieh commented Feb 7, 2026

Summary

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

Base: Root, Trigger, Menu, ThreadItem, NewThreadItem
Styled: Composes base components with Tailwind styling

Fixes TAM-1063

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:12
@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 ThreadDropdown primitives are generally well-factored, but ThreadDropdownRoot currently installs a global keyboard shortcut without scoping/guards or an opt-out, which is risky in a base library. The Trigger also unconditionally passes type="button" even when asChild is used, producing invalid attributes on non-button elements. There are also some ARIA/semantics concerns (role="menu") and a mismatch between the base API and the styled wrapper (base ThreadItem isn’t used).

Additional notes (2)
  • Maintainability | ...ges/react-ui-base/src/thread-dropdown/root/thread-dropdown-root.tsx:10-58
    ThreadDropdownRootProps defines onThreadChange?: () => void, but the callback is invoked in two different situations:

  • after startNewThread() completes

  • immediately after calling switchCurrentThread(threadId)

For consumers, it’s hard to know what changed to what. This often leads to refetching everything or relying on other global state.

Also, switchCurrentThread is called inside a try/catch, but if it’s async (or becomes async later), the try/catch won’t catch promise rejections and onThreadChange could fire before the switch actually completes.

  • Maintainability | ...ase/src/thread-dropdown/thread-item/thread-dropdown-thread-item.tsx:31-66
    ThreadDropdownThreadItem computes a default threadLabel via thread.id.substring(0, 8). If IDs can be shorter than 8 chars this is fine, but if IDs can be non-stringy or formatted differently, you may want the base primitive to be less opinionated.

More importantly: the styled wrapper does not use ThreadDropdownBase.ThreadItem at all; it maps threads and renders Radix items directly. That makes the ThreadItem primitive effectively dead weight for the primary consumer shown here, and makes the base API inconsistent (sometimes render-props, sometimes direct mapping).

Summary of changes

✅ Added ThreadDropdown compound component to react-ui-base

  • Added a new package export entry for ./thread-dropdown in packages/react-ui-base/package.json (CJS/ESM + types).
  • Exported the new namespace component and related types from packages/react-ui-base/src/index.ts.
  • Introduced new headless primitives under packages/react-ui-base/src/thread-dropdown/*:
    • Root (context + data fetching + keyboard shortcut)
    • Trigger
    • Menu
    • ThreadItem
    • NewThreadItem
  • Implemented shared context (ThreadDropdownContext) and hook (useThreadDropdownContext).

✅ Updated ui-registry styled wrapper to use base primitives

  • Reworked packages/ui-registry/src/components/thread-dropdown/thread-dropdown.tsx to compose @tambo-ai/react-ui-base/thread-dropdown primitives with Radix DropdownMenu and Tailwind styling.
  • Updated config.json dependencies to include @tambo-ai/react-ui-base@next and removed class-variance-authority.

Comment on lines +60 to +73
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.altKey && event.shiftKey && event.key === "n") {
event.preventDefault();
void handleNewThread();
}
};

document.addEventListener("keydown", handleKeyDown);

return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [handleNewThread]);
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.

ThreadDropdownRoot installs a global document.addEventListener("keydown", ...) regardless of whether the dropdown is open, and regardless of where the component is rendered. This creates a few concrete problems:

  • Behavioral conflicts: Alt+Shift+N will fire even while the user is typing in an <input>/<textarea> or using the shortcut elsewhere.
  • Multi-mount duplication: Rendering more than one ThreadDropdown.Root (e.g., multiple panels) will register multiple listeners and can trigger multiple thread creations.
  • Accessibility/UX: Global shortcuts should generally be scoped (only when menu is open/focused) or at least ignore editable targets.

Given this is a base primitive in react-ui-base, it’s especially important not to impose a global shortcut with no opt-out.

Suggestion

Consider scoping or hardening the shortcut handling:

  • Ignore events originating from editable elements.
  • Allow opting out via a prop like enableKeyboardShortcut?: boolean (default true) or shortcut?: { enabled: boolean; combo: ... }.
  • Optionally only bind while the dropdown/menu is open (if you can accept an open state prop or integrate with Radix in the styled layer instead of base).

Example (minimal hardening):

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

    if (isEditable) 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 implementing an opt-out prop + editable-target guard.

Comment on lines +15 to +30
export const ThreadDropdownTrigger = React.forwardRef<
HTMLButtonElement,
ThreadDropdownTriggerProps
>(({ asChild, children, ...props }, ref) => {
const Comp = asChild ? Slot : "button";

return (
<Comp
ref={ref}
type="button"
data-slot="thread-dropdown-trigger"
{...props}
>
{children}
</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.

ThreadDropdownTrigger unconditionally sets type="button". When asChild is used (as it is in ui-registry via Radix DropdownMenu.Trigger asChild), the child is a Slot and may ultimately render a non-<button> element. Passing a type attribute to a non-button element is invalid HTML and can create confusing DOM output.

This is a common gotcha with asChild patterns: element-specific attributes should only be applied when you control the underlying tag.

Suggestion

Only apply type="button" when rendering an actual button, or gate it behind !asChild.

export const ThreadDropdownTrigger = React.forwardRef<
  HTMLButtonElement,
  ThreadDropdownTriggerProps
>(({ asChild, children, ...props }, ref) => {
  const Comp = asChild ? Slot : "button";

  return (
    <Comp
      ref={ref}
      {...(!asChild ? { type: "button" } : {})}
      data-slot="thread-dropdown-trigger"
      {...props}
    >
      {children}
    </Comp>
  );
});

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

Comment on lines +51 to +60
return (
<Comp
ref={ref}
data-slot="thread-dropdown-menu"
role="menu"
{...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.

The base ThreadDropdownMenu sets role="menu", while in the styled wrapper the actual interactive menu is Radix DropdownMenu.Content / DropdownMenu.Item, which already applies appropriate roles/ARIA.

This can lead to nested menu roles or conflicting semantics depending on how consumers compose ThreadDropdown.Menu (right now it’s used purely as a render-props provider, not as a DOM wrapper with its own menu semantics).

Suggestion

If ThreadDropdownMenu is primarily a data/render-props provider, consider not forcing role="menu" at the base level. Alternatively, only set role when not asChild, or document that it should wrap actual menu content.

Minimal change:

<Comp
  ref={ref}
  data-slot="thread-dropdown-menu"
  {...(!asChild ? { role: "menu" } : {})}
  {...componentProps}
>

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

@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-1063-thread-dropdown branch from 1442fa1 to b48f9d2 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-1063-thread-dropdown branch from b48f9d2 to a82e5bd 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-1063-thread-dropdown 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