Skip to content

feat(react-devtools): add @tambo-ai/react-devtools package#2542

Open
lachieh wants to merge 1 commit intomainfrom
lachieh/feat-tambo-devtools
Open

feat(react-devtools): add @tambo-ai/react-devtools package#2542
lachieh wants to merge 1 commit intomainfrom
lachieh/feat-tambo-devtools

Conversation

@lachieh
Copy link
Contributor

@lachieh lachieh commented Mar 4, 2026

Demo

Screen.Recording.2026-03-03.at.7.05.26.PM.mov

Summary

  • New @tambo-ai/react-devtools package — a floating panel for inspecting registered components and tools
  • TypeScript-style schema display with shiki syntax highlighting
  • Integrated into showcase app and web dashboard

Features

  • Floating trigger button with Tambo logo, opens a bottom panel
  • Component & Tool tabs with sidebar list, search/filter, and detail pane
  • Schema rendering — converts JSON Schema to TypeScript type notation (type InputSchema = { name: string; age?: number; }) with shiki syntax highlighting (dual light/dark themes)
  • Tool schema conversion — raw Zod schemas are converted to JSON Schema via safeSchemaToJsonSchema before display; void/empty output schemas are hidden
  • Resizable panel with pointer drag
  • Theme support — auto-detects light/dark from <html> color-scheme (works with next-themes), or explicit theme prop
  • Production no-op — renders nothing when NODE_ENV !== "development"
  • Keyboard accessible — Escape to close, arrow keys in list, focus management

Integration

  • showcase/<TamboDevtools /> added to template
  • apps/web/<TamboDevtools /> added to TamboProviderWrapper
  • react-sdk/ — re-exports useTamboRegistry for devtools consumption
  • docs/ — reference page added at /docs/reference/devtools

Test Plan

  • cd packages/react-devtools && npx vitest run — 85 tests pass
  • Visual check in showcase app with registered components/tools
  • Visual check in web dashboard
  • Verify no-op in production build

…ing registry inspector

Add a new devtools package that provides a floating panel for inspecting
Tambo component and tool registries at runtime. The panel features:

- Tabbed view for browsing registered components and tools
- Search/filter for quickly finding registry items
- Detail pane showing descriptions, schemas, and tool associations
- Keyboard navigation (ArrowUp/Down, Enter, Escape)
- Drag-to-resize panel height with pointer capture
- localStorage-persisted panel state (open/close, tab, height, selection)
- Light/dark/system theme support (reads color-scheme from html attribute)
- Automatic no-op in production builds
- Portal-based rendering to avoid layout interference
- Reference documentation and showcase integration
@lachieh lachieh requested a review from a team March 4, 2026 00:08
@charliecreates charliecreates bot requested a review from CharlieHelps March 4, 2026 00:08
@vercel
Copy link

vercel bot commented Mar 4, 2026

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

Project Deployment Actions Updated (UTC)
cloud Ready Ready Preview, Comment Mar 4, 2026 0:13am
showcase Ready Ready Preview, Comment Mar 4, 2026 0:13am
tambo-docs Ready Ready Preview, Comment Mar 4, 2026 0:13am

@github-actions github-actions bot added area: web Changes to the web app (apps/web) area: showcase Changes to the showcase app area: ui area: react-sdk Changes to the React SDK area: config Changes to repository configuration files area: documentation Improvements or additions to documentation status: in progress Work is currently being done contributor: tambo-team Created by a Tambo team member change: feat New feature labels Mar 4, 2026
@lachieh lachieh requested a review from MichaelMilstead March 4, 2026 00:09
@pullfrog
Copy link
Contributor

pullfrog bot commented Mar 4, 2026

Adds a new @tambo-ai/react-devtools package — a floating DevTools panel (modeled after TanStack Query DevTools) that lets developers inspect registered Tambo components and tools at runtime.

  • packages/react-devtools/ — new package with Vite build (dual ESM/CJS), "use client" directives, and sideEffects: false; default export renders nothing in production, ./production export always renders
  • packages/react-devtools/src/tambo-devtools.tsx — main component: trigger button, portal rendering, theme detection via useSyncExternalStore + MutationObserver, provider sentinel check
  • packages/react-devtools/src/devtools-panel.tsx — tabbed panel (Components / Tools) with counts, search/filter, and keyboard navigation (Escape to close, ARIA roles)
  • packages/react-devtools/src/use-panel-state.ts — open/close, active tab, and panel height persisted to localStorage via useSyncExternalStore
  • packages/react-devtools/src/use-resize.ts — drag-to-resize using Pointer Events + setPointerCapture with rAF throttling
  • packages/react-devtools/src/schema-to-ts.ts — converts JSON Schema to TypeScript-style display strings; schema-view.tsx renders them with Shiki syntax highlighting
  • packages/react-devtools/src/styles.ts — inline style objects with light/dark variants (no Tailwind dependency)
  • react-sdk/src/providers/tambo-registry-provider.tsx — adds __initialized sentinel to TamboRegistryContext for provider detection; exports useTamboRegistry from react-sdk/src/v1/index.ts
  • apps/web/providers/tambo-provider.tsx, showcase/src/app/template.tsx — integrate <TamboDevtools /> inside existing <TamboProvider>
  • docs/content/docs/reference/devtools/index.mdx — reference documentation for the new package
  • devdocs/plans/2026-03-03-feat-tambo-devtools-panel-plan.md — design plan with rationale for data access (useTamboRegistry over useTambo), styling, and build decisions
  • Tests cover panel state hook, resize hook, schema-to-TS conversion, schema view rendering, and full component integration (trigger, tabs, empty states, provider error)

Pullfrog  | View workflow run | Using Claude Code | Triggered by Pullfrogpullfrog.com𝕏

Copy link
Contributor

@pullfrog pullfrog bot left a comment

Choose a reason for hiding this comment

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

Nice package — the architecture (useSyncExternalStore for panel state, portal rendering, shiki highlighting) is solid and well-decomposed. A few bugs and one bundling issue to address before merging.

Bugs: stale highlighted HTML when switching schemas, missing pointercancel cleanup in resize hook, listbox keyboard-unreachable when no item is selected.

Bundling: the default export tree-shakes the render but not the bundle — shiki and all devtools code still ships in production. Also sideEffects: false will cause bundlers to drop the CSS import. The docs reference the wrong package name throughout.

Pullfrog  | Fix all ➔Fix 👍s ➔View workflow runpullfrog.com𝕏

Comment on lines +27 to +55
useEffect(() => {
if (!tsCode) {
return;
}

let cancelled = false;

const highlight = async () => {
const { codeToHtml } = await import("shiki");
const html = await codeToHtml(tsCode, {
lang: "typescript",
themes: {
light: "github-light",
dark: "github-dark",
},
defaultColor: false,
});

if (!cancelled) {
setHighlightedHtml(html);
}
};

void highlight();

return () => {
cancelled = true;
};
}, [tsCode]);
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: when tsCode changes (user selects a different component/tool), the effect re-runs but highlightedHtml still holds the previous schema's highlighted HTML until the async codeToHtml resolves. This causes a flash of stale content.

Reset state at the top of the effect before the early return:

setHighlightedHtml(null);
if (!tsCode) {
  return;
}

Also — if the dynamic import("shiki") or codeToHtml rejects (network failure, bundling issue), the error is silently swallowed by void highlight(). Consider wrapping the body in try/catch with at minimum a console.warn so devtools users have some signal when highlighting fails.

Comment on lines +71 to +81
const handlePointerUp = () => {
dragState.current.isDragging = false;
if (dragState.current.rafId !== null) {
cancelAnimationFrame(dragState.current.rafId);
}
target.removeEventListener("pointermove", handlePointerMove);
target.removeEventListener("pointerup", handlePointerUp);
};

target.addEventListener("pointermove", handlePointerMove);
target.addEventListener("pointerup", handlePointerUp);
Copy link
Contributor

Choose a reason for hiding this comment

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

Bug: if the pointer is cancelled (browser gesture, touch interruption), pointercancel fires instead of pointerup, leaving pointermove/pointerup listeners permanently attached. Add a pointercancel handler that runs the same cleanup as handlePointerUp, and remove all three listeners in the cleanup path:

const cleanup = () => {
  dragState.current.isDragging = false;
  if (dragState.current.rafId !== null) {
    cancelAnimationFrame(dragState.current.rafId);
  }
  target.removeEventListener("pointermove", handlePointerMove);
  target.removeEventListener("pointerup", cleanup);
  target.removeEventListener("pointercancel", cleanup);
};
target.addEventListener("pointercancel", cleanup);

Comment on lines +2 to +9
import * as Devtools from "./tambo-devtools";

export type { TamboDevtoolsProps } from "./tambo-devtools";

export const TamboDevtools: (typeof Devtools)["TamboDevtools"] =
process.env.NODE_ENV !== "development"
? () => null // Render nothing in production
: Devtools.TamboDevtools;
Copy link
Contributor

Choose a reason for hiding this comment

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

The unconditional import * as Devtools means bundlers must include tambo-devtools.tsx and all its transitive deps (shiki, CSS, MutationObserver hook, etc.) even when NODE_ENV !== 'development'. The ternary prevents rendering but not bundling.

Consider a dynamic import behind the env check, or configure the package.json exports with "development" / "default" conditions so bundlers can resolve to the no-op module in production. The ./production export exists but consumers must opt into it — the default entry should be zero-cost in prod.

Comment on lines +107 to +130
return (
<div role="listbox" aria-label="Registry items">
{items.map((item, index) => {
const isSelected = item.name === selectedItem;
return (
<div
key={item.name}
ref={(el) => setItemRef(item.name, el)}
role="option"
aria-selected={isSelected}
tabIndex={isSelected ? 0 : -1}
style={{
...styles.sidebarItem,
...(isSelected ? styles.sidebarItemActive : {}),
}}
onClick={() => onSelect(item.name)}
onKeyDown={(e) => handleKeyDown(e, index)}
>
{item.name}
</div>
);
})}
</div>
);
Copy link
Contributor

Choose a reason for hiding this comment

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

Accessibility: when no item is selected (selectedItem is null), every option has tabIndex={-1}, making the entire listbox unreachable by keyboard Tab navigation. The roving tabindex pattern requires at least one item with tabIndex={0} as the tab stop. Consider:

tabIndex={isSelected ? 0 : (selectedItem === null && index === 0) ? 0 : -1}

return DEFAULT_STATE;
}
try {
cachedState = { ...DEFAULT_STATE, ...JSON.parse(raw) };
Copy link
Contributor

Choose a reason for hiding this comment

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

Malformed localStorage data (e.g. {"panelHeight": "not-a-number", "activeTab": "bogus"}) gets spread into cachedState without validation, which could cause runtime errors downstream (e.g. height: "not-a-number" passed to a style). Consider validating the parsed shape — at minimum check that panelHeight is a number and activeTab is one of the known values — before merging.

description: Inspect registered components and tools at runtime with a floating DevTools panel.
---

The `@tambo-ai/devtools` package provides a floating panel that surfaces your registered components and tools at runtime. When a component or tool doesn't appear in AI responses, open the panel to verify what's actually registered, what schemas are configured, and which tools are associated with which components.
Copy link
Contributor

Choose a reason for hiding this comment

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

The package name here and throughout this doc is @tambo-ai/devtools, but the actual package is @tambo-ai/react-devtools. The install command (line 11), imports (lines 20, 41), and references (line 36) all need to be updated to @tambo-ai/react-devtools.

},
"license": "MIT",
"type": "module",
"sideEffects": false,
Copy link
Contributor

Choose a reason for hiding this comment

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

"sideEffects": false tells bundlers it's safe to tree-shake any unused module. Since tambo-devtools.tsx has a bare import "./devtools.css" side-effect import, bundlers (webpack, etc.) may drop the CSS file entirely. Change to "sideEffects": ["**/*.css"] to preserve CSS imports while still allowing JS tree-shaking.

}
};

const renderType = (schema: JsonSchema, depth: number): string => {
Copy link
Contributor

Choose a reason for hiding this comment

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

No recursion depth guard on renderType. A pathological or circular schema (e.g. via unresolved $ref cycles or deeply nested anyOf/allOf) will blow the stack. Consider adding a MAX_DEPTH constant (~20) and returning "unknown /* too deep */" when exceeded.

Comment on lines +37 to +38
/** Whether this context was provided by a real TamboRegistryProvider (not the default) */
__initialized: boolean;
Copy link
Contributor

Choose a reason for hiding this comment

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

Nit: __initialized with the dunder prefix is now part of the public TamboRegistryContext interface that external packages depend on. A name like isProviderMounted would make it a more intentional public API citizen. Up to you whether to change it now or later.

Comment on lines +131 to +145
useEffect(() => {
const existing = document.querySelector("[data-tambo-devtools-portal]");
if (existing) {
containerRef.current = existing;
return;
}
const el = document.createElement("div");
el.setAttribute("data-tambo-devtools-portal", "");
document.body.appendChild(el);
containerRef.current = el;
return () => {
el.remove();
containerRef.current = null;
};
}, []);
Copy link
Contributor

Choose a reason for hiding this comment

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

On first render when isOpen is true (via initialOpen), containerRef.current is still null because this effect hasn't run yet. The guard at line 178 returns null, causing a one-frame flash before the portal appears. Consider useState + lazy initializer so the container is available synchronously:

const [container] = useState(() => {
  if (typeof document === 'undefined') return null;
  const existing = document.querySelector('[data-tambo-devtools-portal]');
  if (existing) return existing as HTMLElement;
  const el = document.createElement('div');
  el.setAttribute('data-tambo-devtools-portal', '');
  document.body.appendChild(el);
  return el;
});

Then clean it up in a useEffect return.

Copy link
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 correctness issue is DevtoolsPanel using a ref-only portal container, which can prevent the panel from rendering on first open because the container creation doesn’t trigger a re-render. There’s also a security concern: SchemaView uses dangerouslySetInnerHTML with content derived from potentially untrusted schema metadata (descriptions/keys), creating an XSS risk. Additionally, jsonSchemaToTs produces invalid TS for non-identifier property names, and the docs/package naming appears inconsistent (@tambo-ai/devtools vs @tambo-ai/react-devtools).

Summary of changes

Summary of changes

App integration

  • Added @tambo-ai/react-devtools dependency to:
    • apps/web/package.json
    • showcase/package.json
  • Mounted the devtools UI inside existing providers:
    • apps/web/providers/tambo-provider.tsx: renders <TamboDevtools /> under <TamboProvider>
    • showcase/src/app/template.tsx: renders <TamboDevtools /> under <TamboProvider>

New package: packages/react-devtools

  • Introduced a new workspace package @tambo-ai/react-devtools with:
    • Dual entrypoints: src/index.ts (dev-only no-op in prod) and src/production.ts (always renders)
    • Vite + Vitest build/test configuration (vite.config.ts, vitest.setup.ts)
    • Devtools UI implementation:
      • tambo-devtools.tsx (trigger + state + registry wiring)
      • devtools-panel.tsx (portal dialog, tabs, search, resize)
      • registry-list.tsx, detail-view.tsx, schema-view.tsx
      • use-panel-state.ts (localStorage via useSyncExternalStore), use-resize.ts (pointer-based resizing)
      • schema-to-ts.ts + tests for JSON Schema → TypeScript-like rendering and Shiki highlighting
    • Shared styles via CSS variables + Shiki theme mapping (devtools.css) and inline style objects (styles.ts)

react-sdk registry context sentinel

  • Added __initialized sentinel to TamboRegistryContext:
    • Default context now includes __initialized: false
    • Provider value includes __initialized: true as const
  • Re-exported useTamboRegistry from react-sdk/src/v1/index.ts
  • Updated tests/mocks to include __initialized across affected suites.

Docs & planning

  • Added a devdocs plan and a new docs reference page for DevTools.
  • Updated docs reference navigation to include the new page.

Lockfile updates

  • Updated package-lock.json for new workspace package(s) and new dev dependencies (notably Vitest-related entries).

Comment on lines +158 to +165
const handleToggle = () => {
panelState.toggle();
// Return focus to trigger when closing
if (panelState.isOpen) {
triggerRef.current?.focus();
}
};

Copy link
Contributor

Choose a reason for hiding this comment

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

handleToggle() attempts to return focus to the trigger while closing, but it checks panelState.isOpen immediately after calling panelState.toggle(). Since toggle() persists to localStorage + emits external-store updates, the value of panelState.isOpen inside this handler is the pre-toggle value. This means focus return is relying on subtle timing and is easy to break if toggle() changes implementation.

Also, focus return should happen after the panel is actually closed (and trigger is mounted again). Right now it can run before the panel unmounts/mounts, and triggerRef.current may not be in the tree when the panel is open (you conditionally render the button only when closed).

Suggestion

Make focus return deterministic by moving it into an effect that runs when panelState.isOpen transitions to false, or compute wasOpen explicitly.

Example:

const wasOpenRef = useRef(panelState.isOpen);
useEffect(() => {
  if (wasOpenRef.current && !panelState.isOpen) {
    // panel just closed; trigger is now mounted
    triggerRef.current?.focus();
  }
  wasOpenRef.current = panelState.isOpen;
}, [panelState.isOpen]);

const handleToggle = () => {
  panelState.toggle();
};

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

Comment on lines +57 to +96
const useResolvedDark = (): boolean =>
useSyncExternalStore(
useCallback((cb: () => void) => {
if (typeof window === "undefined") {
return () => {};
}

// Watch <html> style attribute for color-scheme changes (next-themes, etc.)
const observer = new MutationObserver(cb);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["style", "class"],
});

// Watch OS-level preference as fallback
const mql = window.matchMedia("(prefers-color-scheme: dark)");
mql.addEventListener("change", cb);

return () => {
observer.disconnect();
mql.removeEventListener("change", cb);
};
}, []),
() => {
if (typeof window === "undefined") {
return false;
}
// Prefer explicit color-scheme on <html> (set by next-themes, etc.)
const htmlColorScheme = document.documentElement.style.colorScheme;
if (htmlColorScheme === "dark") {
return true;
}
if (htmlColorScheme === "light") {
return false;
}
// Fall back to OS preference
return window.matchMedia("(prefers-color-scheme: dark)").matches;
},
() => false,
);
Copy link
Contributor

Choose a reason for hiding this comment

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

useResolvedDark() adds a MutationObserver and matchMedia listener for every TamboDevtools instance. With HMR, microfrontends, or accidental multiple mounts, this can duplicate observers/listeners and cause extra work. Since this hook is only used to decide a theme class, it’s better implemented as a shared singleton external-store (module-level) or at least guarded so only one observer/listener is active per document.

Also, observing both style and class but only reading document.documentElement.style.colorScheme is inconsistent: changes via class-based theming won’t affect style.colorScheme, so you’ll still pay observer cost without gaining correctness.

Suggestion

Refactor theme detection to a module-level external store so multiple mounts share a single observer/listener, and make the observed attributes match what you actually read.

Example approach:

// theme-store.ts
let listeners = new Set<() => void>();
let cleanup: null | (() => void) = null;

function ensureSubscribed() {
  if (cleanup || typeof window === "undefined") return;
  const cb = () => listeners.forEach(l => l());
  const observer = new MutationObserver(cb);
  observer.observe(document.documentElement, { attributes: true, attributeFilter: ["style"] });
  const mql = window.matchMedia("(prefers-color-scheme: dark)");
  mql.addEventListener("change", cb);
  cleanup = () => { observer.disconnect(); mql.removeEventListener("change", cb); cleanup = null; };
}

export function subscribeTheme(listener: () => void) {
  ensureSubscribed();
  listeners.add(listener);
  return () => {
    listeners.delete(listener);
    if (listeners.size === 0) cleanup?.();
  };
}

export function getThemeSnapshot() {
  const htmlColorScheme = document.documentElement.style.colorScheme;
  if (htmlColorScheme === "dark") return true;
  if (htmlColorScheme === "light") return false;
  return window.matchMedia("(prefers-color-scheme: dark)").matches;
}

Then in useResolvedDark: useSyncExternalStore(subscribeTheme, getThemeSnapshot, () => false).

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

Comment on lines +121 to +180
const containerRef = useRef<Element | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const listRef = useRef<RegistryListHandle>(null);

// Clear search when tab changes
useEffect(() => {
setSearchQuery("");
}, [activeTab]);

// Create portal container
useEffect(() => {
const existing = document.querySelector("[data-tambo-devtools-portal]");
if (existing) {
containerRef.current = existing;
return;
}
const el = document.createElement("div");
el.setAttribute("data-tambo-devtools-portal", "");
document.body.appendChild(el);
containerRef.current = el;
return () => {
el.remove();
containerRef.current = null;
};
}, []);

// Keyboard: Escape to close
useEffect(() => {
if (!isOpen) {
return;
}
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
close();
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [isOpen, close]);

// Focus management: move focus to panel on open
useEffect(() => {
if (isOpen && panelRef.current) {
panelRef.current.focus();
}
}, [isOpen]);

const activeItems = activeTab === "components" ? componentItems : toolItems;
const filteredItems = useMemo(
() => filterItems(activeItems, searchQuery),
[activeItems, searchQuery],
);
const selectedItemData = useMemo(
() => activeItems.find((item) => item.name === selectedItem) ?? null,
[activeItems, selectedItem],
);

if (!isOpen || !containerRef.current) {
return null;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

The portal container is stored only in a ref (containerRef.current). Because refs don’t trigger re-renders, DevtoolsPanel can render null on the first open if the container isn’t created yet, and then never re-render when containerRef.current becomes non-null. The only reason this might “work” is if some other state change happens to re-render (which is fragile).

This is a correctness issue: first-time open can fail to display the panel depending on timing/React scheduling.

Suggestion

Store the portal container in state (or force a re-render after creation) so the component reliably re-renders when the container becomes available.

Example:

const [container, setContainer] = useState<Element | null>(null);

useEffect(() => {
  const existing = document.querySelector("[data-tambo-devtools-portal]");
  if (existing) {
    setContainer(existing);
    return;
  }
  const el = document.createElement("div");
  el.setAttribute("data-tambo-devtools-portal", "");
  document.body.appendChild(el);
  setContainer(el);
  return () => el.remove();
}, []);

if (!isOpen || !container) return null;
return createPortal(..., container);

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

Comment on lines +130 to +145
// Create portal container
useEffect(() => {
const existing = document.querySelector("[data-tambo-devtools-portal]");
if (existing) {
containerRef.current = existing;
return;
}
const el = document.createElement("div");
el.setAttribute("data-tambo-devtools-portal", "");
document.body.appendChild(el);
containerRef.current = el;
return () => {
el.remove();
containerRef.current = null;
};
}, []);
Copy link
Contributor

Choose a reason for hiding this comment

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

The portal container effect removes el unconditionally on cleanup. If multiple devtools instances mount, the second one will reuse the existing [data-tambo-devtools-portal] element, but when the first instance unmounts it will remove the shared container out from under the second instance.

This is a correctness bug that will show up with duplicate mounts (HMR edge cases, multiple providers, or tests that mount/unmount).

Suggestion

Only remove the portal element if this instance created it. Track ownership with a ref.

const ownsContainerRef = useRef(false);
useEffect(() => {
  const existing = document.querySelector("[data-tambo-devtools-portal]");
  if (existing) {
    containerRef.current = existing;
    ownsContainerRef.current = false;
    return;
  }
  const el = document.createElement("div");
  el.setAttribute("data-tambo-devtools-portal", "");
  document.body.appendChild(el);
  containerRef.current = el;
  ownsContainerRef.current = true;
  return () => {
    if (ownsContainerRef.current) el.remove();
    containerRef.current = null;
  };
}, []);

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

Comment on lines +168 to +176
const activeItems = activeTab === "components" ? componentItems : toolItems;
const filteredItems = useMemo(
() => filterItems(activeItems, searchQuery),
[activeItems, searchQuery],
);
const selectedItemData = useMemo(
() => activeItems.find((item) => item.name === selectedItem) ?? null,
[activeItems, selectedItem],
);
Copy link
Contributor

Choose a reason for hiding this comment

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

filterItems() returns the original items array when query is empty, but returns a new array when there is a query. That means the RegistryList receives a different items reference after clearing the search, which is fine, but selection/focus behavior relies on selectedItem and itemRefs keyed by name.

The real problem: focus behavior can become surprising when selectedItem points to an item filtered out—selectedItemData is computed from activeItems (unfiltered), so the detail view can show an item that is not present in the list, and list keyboard navigation can’t reach it.

Suggestion

When filteredItems no longer contains selectedItem, clear the selection (and thus detail view) to keep list + detail consistent.

useEffect(() => {
  if (selectedItem && !filteredItems.some(i => i.name === selectedItem)) {
    setSelectedItem(null);
  }
}, [filteredItems, selectedItem, setSelectedItem]);

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

Comment on lines +27 to +55
useEffect(() => {
if (!tsCode) {
return;
}

let cancelled = false;

const highlight = async () => {
const { codeToHtml } = await import("shiki");
const html = await codeToHtml(tsCode, {
lang: "typescript",
themes: {
light: "github-light",
dark: "github-dark",
},
defaultColor: false,
});

if (!cancelled) {
setHighlightedHtml(html);
}
};

void highlight();

return () => {
cancelled = true;
};
}, [tsCode]);
Copy link
Contributor

Choose a reason for hiding this comment

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

dangerouslySetInnerHTML is unavoidable for Shiki, but right now the code will happily render whatever HTML Shiki returns for whatever string you pass it. Because jsonSchemaToTs includes schema description values in JSDoc, if upstream schema descriptions can include */ + extra tokens, the emitted code could become confusing. It likely won’t become an XSS issue because Shiki should escape code tokens, but you’re trusting an HTML generator at runtime.

At minimum, you should ensure the fallback path is used when Shiki fails (network/loader error), otherwise a rejected import will leave the component permanently in plain fallback or in a partial state depending on timing.

Suggestion

Add error handling in the async highlight function and explicitly fall back to plain text on failure.

const highlight = async () => {
  try {
    const { codeToHtml } = await import("shiki");
    const html = await codeToHtml(tsCode, { /* ... */ });
    if (!cancelled) setHighlightedHtml(html);
  } catch {
    if (!cancelled) setHighlightedHtml(null);
  }
};

Optionally also sanitize/normalize description strings in jsonSchemaToTs (e.g., replace newlines) to reduce weird formatting.

Reply with "@CharlieHelps yes please" if you'd like me to add a commit with this try/catch fallback.

Comment on lines +127 to +134
const lines = Object.entries(schema.properties).map(([key, propSchema]) => {
const optional = requiredSet.has(key) ? "" : "?";
const propType = renderType(propSchema, depth + 1);
const comment = propSchema.description
? `${indent}/** ${propSchema.description} */\n`
: "";
return `${comment}${indent}${key}${optional}: ${propType};`;
});
Copy link
Contributor

Choose a reason for hiding this comment

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

jsonSchemaToTs() renders object property keys directly as TypeScript identifiers (e.g. ${key}${optional}: ...). JSON Schema property names can include characters that are not valid identifiers (-, spaces, quotes) or collide with reserved words.

This will produce misleading/broken TypeScript output for many real-world schemas and undermines the devtools’ core purpose (accurate schema display).

Suggestion

Quote property keys when they are not valid identifiers (or always quote). Minimal approach:

const isValidIdentifier = (k: string) => /^[$A-Z_][0-9A-Z_$]*$/i.test(k);
const renderKey = (k: string) => (isValidIdentifier(k) ? k : JSON.stringify(k));
...
return `${comment}${indent}${renderKey(key)}${optional}: ${propType};`;

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

Comment on lines +21 to +33
const listeners = new Set<() => void>();

// Cached snapshot for referential stability (useSyncExternalStore requirement)
let cachedRaw: string | null = null;
let cachedState: PanelState = DEFAULT_STATE;

const emitChange = () => {
// Invalidate cache so getSnapshot reads fresh
cachedRaw = null;
for (const listener of listeners) {
listener();
}
};
Copy link
Contributor

Choose a reason for hiding this comment

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

usePanelState keeps listeners and the storage cache (cachedRaw/cachedState) as module-level singletons. In practice, this means all instances of the package (and potentially multiple React roots) share one global state bus.

That may be intended for a singleton devtools, but it also means:

  • state leaks across multiple apps mounted on the same page
  • tests running in the same environment can influence each other if localStorage keys collide
  • HMR boundaries can create odd listener duplication

If singleton behavior is intended, it should be explicit and enforced at the component level (and ideally avoid module global listeners).

Suggestion

If you want singleton semantics, enforce it where the UI is mounted (e.g., portal singleton check + warning) and keep the store implementation instance-safe.

At minimum, namespace the storage key by origin/app (or allow overriding via option), e.g. tambo-devtools-state:${window.location.host}. Alternatively, accept a storageKey option.

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

Comment on lines +6 to +44
The `@tambo-ai/devtools` package provides a floating panel that surfaces your registered components and tools at runtime. When a component or tool doesn't appear in AI responses, open the panel to verify what's actually registered, what schemas are configured, and which tools are associated with which components.

## Installation

```bash title="Install the package"
npm install @tambo-ai/devtools
```

## Quick start

Place `<TamboDevtools />` anywhere inside your `<TamboProvider>`:

```tsx
import { TamboProvider } from "@tambo-ai/react";
import { TamboDevtools } from "@tambo-ai/devtools";

function App() {
return (
<TamboProvider apiKey="your-api-key">
<YourApp />
<TamboDevtools />
</TamboProvider>
);
}
```

A floating button appears in the bottom-right corner. Click it to open the panel.

## Production behavior

The default import (`@tambo-ai/devtools`) renders nothing when `process.env.NODE_ENV` is not `"development"`. Bundlers eliminate the component code entirely in production builds.

If you need the devtools in a production or staging environment, use the `/production` export:

```tsx
import { TamboDevtools } from "@tambo-ai/devtools/production";
```

This always renders the panel regardless of environment.
Copy link
Contributor

Choose a reason for hiding this comment

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

You introduced two devtools package names in-repo: @tambo-ai/devtools (docs/plan/lockfile shows packages/devtools) and @tambo-ai/react-devtools (actual new package + app integration). This inconsistency is likely to confuse consumers and can lead to publishing/versioning mistakes.

In particular, the new docs page instructs installing @tambo-ai/devtools, but the apps are consuming @tambo-ai/react-devtools.

Suggestion

Align naming across:

  • docs (index.mdx)
  • plan doc
  • workspace package folder/name
  • app imports

If the intended public package is @tambo-ai/react-devtools, update docs to reference that package and the /production export accordingly.

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 March 4, 2026 00:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: config Changes to repository configuration files area: documentation Improvements or additions to documentation area: react-sdk Changes to the React SDK area: showcase Changes to the showcase app area: ui area: web Changes to the web app (apps/web) 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