feat(react-devtools): add @tambo-ai/react-devtools package#2542
feat(react-devtools): add @tambo-ai/react-devtools package#2542
Conversation
…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
|
Adds a new
|
There was a problem hiding this comment.
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.
| 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]); |
There was a problem hiding this comment.
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.
| 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); |
There was a problem hiding this comment.
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);| 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; |
There was a problem hiding this comment.
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.
| 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> | ||
| ); |
There was a problem hiding this comment.
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) }; |
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
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, |
There was a problem hiding this comment.
"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 => { |
There was a problem hiding this comment.
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.
| /** Whether this context was provided by a real TamboRegistryProvider (not the default) */ | ||
| __initialized: boolean; |
There was a problem hiding this comment.
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.
| 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; | ||
| }; | ||
| }, []); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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-devtoolsdependency to:apps/web/package.jsonshowcase/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-devtoolswith:- Dual entrypoints:
src/index.ts(dev-only no-op in prod) andsrc/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.tsxuse-panel-state.ts(localStorage viauseSyncExternalStore),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)
- Dual entrypoints:
react-sdk registry context sentinel
- Added
__initializedsentinel toTamboRegistryContext:- Default context now includes
__initialized: false - Provider value includes
__initialized: true as const
- Default context now includes
- Re-exported
useTamboRegistryfromreact-sdk/src/v1/index.ts - Updated tests/mocks to include
__initializedacross 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.jsonfor new workspace package(s) and new dev dependencies (notably Vitest-related entries).
| const handleToggle = () => { | ||
| panelState.toggle(); | ||
| // Return focus to trigger when closing | ||
| if (panelState.isOpen) { | ||
| triggerRef.current?.focus(); | ||
| } | ||
| }; | ||
|
|
There was a problem hiding this comment.
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.
| 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, | ||
| ); |
There was a problem hiding this comment.
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.
| 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; | ||
| } |
There was a problem hiding this comment.
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.
| // 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; | ||
| }; | ||
| }, []); |
There was a problem hiding this comment.
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.
| 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], | ||
| ); |
There was a problem hiding this comment.
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.
| 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]); |
There was a problem hiding this comment.
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.
| 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};`; | ||
| }); |
There was a problem hiding this comment.
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.
| 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(); | ||
| } | ||
| }; |
There was a problem hiding this comment.
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
localStoragekeys 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.
| 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. |
There was a problem hiding this comment.
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.

Demo
Screen.Recording.2026-03-03.at.7.05.26.PM.mov
Summary
@tambo-ai/react-devtoolspackage — a floating panel for inspecting registered components and toolsFeatures
type InputSchema = { name: string; age?: number; }) with shiki syntax highlighting (dual light/dark themes)safeSchemaToJsonSchemabefore display; void/empty output schemas are hidden<html>color-scheme (works with next-themes), or explicitthemepropNODE_ENV !== "development"Integration
showcase/—<TamboDevtools />added to templateapps/web/—<TamboDevtools />added toTamboProviderWrapperreact-sdk/— re-exportsuseTamboRegistryfor devtools consumptiondocs/— reference page added at/docs/reference/devtoolsTest Plan
cd packages/react-devtools && npx vitest run— 85 tests pass