React components and runtime for building Timbal chat UIs. Drop in a single component to get a fully-featured streaming chat interface connected to a Timbal workforce agent.
npm install @timbal-ai/timbal-react
# or
bun add @timbal-ai/timbal-reactPeer dependencies:
npm install react react-dom @assistant-ui/react @timbal-ai/timbal-sdkThe package ships pre-built Tailwind class names plus a complete light + dark token set (bg-background, text-foreground, bg-card, bg-bubble-user, from-elevated-from, bg-playground-from/via/to, etc.). Your app CSS only needs three lines:
/* src/index.css */
@import "tailwindcss";
@import "@timbal-ai/timbal-react/styles.css";
@source "../node_modules/@timbal-ai/timbal-react/dist";That's it — no @theme, :root, or .dark blocks of your own. Toggling dark mode is a single document.documentElement.classList.toggle("dark") (or next-themes attribute="class").
Adjust the
@sourcepath if your CSS file lives at a different depth relative tonode_modules.
Every token has a CSS-variable indirection in styles.css. Override individual variables to rebrand without forking:
:root {
--primary: oklch(0.5 0.12 265);
--playground-from: oklch(0.95 0.04 265 / 0.6);
}
.dark {
--primary: oklch(0.72 0.14 265);
--playground-from: oklch(0.27 0.04 265);
}Both light AND dark blocks must be defined for every overridden token — otherwise toggling dark mode produces an inconsistent UI. The library prints a one-time dev-only console warning when it detects a mismatch.
Import these stylesheets once in your app entry:
// src/main.tsx
import "@assistant-ui/react-markdown/styles/dot.css";
import "katex/dist/katex.min.css";TimbalChat is a single component that handles everything — runtime, streaming, messages, and the composer:
import { TimbalChat } from "@timbal-ai/timbal-react";
export default function App() {
return (
<div style={{ height: "100vh" }}>
<TimbalChat workforceId="your-workforce-id" />
</div>
);
}
TimbalChatrequires a fixed height parent. Useheight: "100vh"orflex-1 min-h-0depending on your layout.
<TimbalChat
workforceId="your-workforce-id"
welcome={{
heading: "Hi, I'm your assistant",
subheading: "Ask me anything about your data.",
}}
suggestions={[
{ title: "Summarize this week", description: "Get a quick overview of recent activity" },
{ title: "What can you help with?" },
{ title: "Show me the latest report" },
]}
/>Suggestions also accept a function (sync or async) for per-user or server-driven chips:
<TimbalChat
workforceId="your-workforce-id"
suggestions={async () => {
const res = await authFetch("/api/suggestions");
return res.json(); // ThreadSuggestion[]
}}
/>Each chip supports icon, description, and prompt (sent instead of title when clicked).
<TimbalChat
workforceId="your-workforce-id"
composerPlaceholder="Type a question..."
maxWidth="60rem"
className="my-custom-class"
/>Pass key to fully reset the chat when the workforce changes:
const [workforceId, setWorkforceId] = useState("agent-a");
<select onChange={(e) => setWorkforceId(e.target.value)}>
<option value="agent-a">Agent A</option>
<option value="agent-b">Agent B</option>
</select>
<TimbalChat workforceId={workforceId} key={workforceId} />TimbalStudioShell is the most opinionated layout — a floating workforce sidebar, a top bar for actions (mode toggle, account), and a full-height TimbalChat. Works as a one-line app:
import {
TimbalStudioShell,
ModeToggle,
TimbalMark,
} from "@timbal-ai/timbal-react";
import { useTheme } from "next-themes";
export default function App() {
const { resolvedTheme, setTheme } = useTheme();
return (
<TimbalStudioShell
brand={<TimbalMark size={32} />}
welcome={{ heading: "How can I help you today?" }}
headerActions={
<ModeToggle theme={resolvedTheme} setTheme={setTheme} />
}
suggestions={[{ title: "Get started" }]}
attachments
/>
);
}When workforceId is omitted, the shell fetches the workforce list and lets the sidebar drive selection. Pass workforceId to pin a single agent and hide the picker UI.
Apps that need finer control can compose the public building blocks (StudioSidebar + TimbalChat) directly:
import { StudioSidebar, TimbalChat } from "@timbal-ai/timbal-react";
function MyShell() {
const [agent, setAgent] = useState("agent-a");
return (
<div className="relative h-dvh bg-background">
<StudioSidebar selectedId={agent} onSelect={setAgent} />
<main className="h-full pl-[var(--studio-inset-left)]">
<TimbalChat workforceId={agent} key={agent} />
</main>
</div>
);
}The --studio-inset-left CSS variable is automatically set on the document by StudioSidebar, so a normal Tailwind pl-[var(--studio-inset-left)] call resolves to the correct offset.
TimbalChatShell wraps the common blueprint layout: brand area, workforce selector, optional header actions, and a full-height chat. When workforceId is omitted, it fetches {baseUrl}/workforce and selects the first agent automatically:
import { TimbalChatShell, Button, useSession } from "@timbal-ai/timbal-react";
export default function App() {
const { logout, isAuthenticated } = useSession();
return (
<TimbalChatShell
brand={<span className="font-semibold">Acme AI</span>}
headerActions={
isAuthenticated ? (
<Button variant="ghost" size="sm" onClick={logout}>
Log out
</Button>
) : null
}
welcome={{ heading: "How can I help you today?" }}
suggestions={[{ title: "Get started" }]}
/>
);
}Pass workforceId to lock the agent and hide the built-in selector. Use hideWorkforceSelector when you render your own picker.
For custom layouts (sidebar tree, command palette), use useWorkforces with the optional WorkforceSelector:
import {
TimbalChat,
useWorkforces,
WorkforceSelector,
} from "@timbal-ai/timbal-react";
function ChatWithPicker() {
const { workforces, selectedId, setSelectedId, isLoading } = useWorkforces();
if (isLoading) return <div>Loading agents…</div>;
return (
<div className="flex h-screen flex-col">
<WorkforceSelector
workforces={workforces}
value={selectedId}
onChange={setSelectedId}
/>
<TimbalChat workforceId={selectedId} key={selectedId} className="min-h-0 flex-1" />
</div>
);
}useWorkforces accepts baseUrl, fetch, and pickInitial (custom resolver for the default selection). It returns selected, error, and refresh() as well.
TimbalChat is a convenience wrapper around TimbalRuntimeProvider + Thread. Use them separately when you need to place the runtime above the chat — for example, to build a custom header that reads or controls chat state:
import { TimbalRuntimeProvider, Thread } from "@timbal-ai/timbal-react";
export default function App() {
return (
<TimbalRuntimeProvider workforceId="your-workforce-id">
<div style={{ height: "100vh", display: "flex", flexDirection: "column" }}>
<header>My App</header>
<Thread
composerPlaceholder="Ask anything..."
className="flex-1 min-h-0"
/>
</div>
</TimbalRuntimeProvider>
);
}Useful when your API is mounted at a subpath (e.g. behind a reverse proxy):
<TimbalRuntimeProvider workforceId="your-workforce-id" baseUrl="/api">
<Thread />
</TimbalRuntimeProvider>Attachments are opt-in. Pass attachments to enable the composer + button, drag-and-drop, and multimodal prompts:
<TimbalChat workforceId="your-workforce-id" attachments />When enabled, each file is uploaded via POST to ${baseUrl}/files/upload (multipart file field). The response must include { url } (or { signed_url } / { id }). That URL is sent to the workforce as { type: "file", file: "<url>" } alongside { type: "text", text: "..." } when the user typed a message.
Your API must expose that upload route (the Timbal blueprint API includes it). authFetch is used by default and must not force a Content-Type header on FormData uploads.
// Default upload adapter
<TimbalChat workforceId="..." attachments />
// Custom endpoint or MIME whitelist
<TimbalChat
workforceId="..."
attachments={{ uploadUrl: "/api/uploads", accept: "image/*,application/pdf" }}
/>
// Fully custom adapter (e.g. presigned S3)
<TimbalChat workforceId="..." attachments={myAdapter} />
// Explicitly off (default when prop is omitted)
<TimbalChat workforceId="..." attachments={null} />import {
createDefaultAttachmentAdapter,
createUploadAttachmentAdapter,
resolveAttachmentAdapter,
parseSSELine,
AssistantRuntimeProvider,
useTimbalStream,
} from "@timbal-ai/timbal-react";parseSSELine and AssistantRuntimeProvider are re-exported so custom runtimes do not need a second @assistant-ui/react import for those symbols. useTimbalStream exposes the same SSE reducer and send / reload / cancel API without mounting <Thread>.
Pass your own fetch to add headers, inject tokens, or proxy requests:
const myFetch: typeof fetch = (url, options) => {
return fetch(url, {
...options,
headers: { ...options?.headers, "X-My-Header": "value" },
});
};
<TimbalRuntimeProvider workforceId="your-workforce-id" fetch={myFetch}>
<Thread />
</TimbalRuntimeProvider>Use the components prop on TimbalChat or Thread to replace any part of the interface while keeping everything else as the default.
| Slot | Props forwarded | Default |
|---|---|---|
UserMessage |
none | built-in user bubble |
AssistantMessage |
none | built-in assistant bubble |
EditComposer |
none | built-in inline edit composer |
Composer |
placeholder (+ full ComposerProps) |
built-in composer bar |
Welcome |
config, suggestions, Suggestions |
built-in welcome screen |
Suggestions |
suggestions |
built-in suggestion chips |
ScrollToBottom |
none | built-in scroll button |
Custom slot components read their data via hooks — no props are passed automatically except where noted above.
import { TimbalChat, MessagePrimitive } from "@timbal-ai/timbal-react";
const CompactUserMessage = () => (
<MessagePrimitive.Root className="flex justify-end px-4 py-2">
<div className="bg-primary text-primary-foreground rounded-2xl px-4 py-2 text-sm max-w-[75%]">
<MessagePrimitive.Parts />
</div>
</MessagePrimitive.Root>
);
<TimbalChat workforceId="..." components={{ UserMessage: CompactUserMessage }} />The Composer slot receives placeholder from the composerPlaceholder prop:
import { TimbalChat, ComposerPrimitive } from "@timbal-ai/timbal-react";
const MinimalComposer = ({ placeholder }: { placeholder?: string }) => (
<ComposerPrimitive.Root className="flex items-center gap-2 border rounded-full px-4 py-2">
<ComposerPrimitive.Input
placeholder={placeholder ?? "Type here..."}
className="flex-1 bg-transparent text-sm outline-none"
rows={1}
/>
<ComposerPrimitive.Send className="text-primary font-medium text-sm">
Send
</ComposerPrimitive.Send>
</ComposerPrimitive.Root>
);
<TimbalChat workforceId="..." components={{ Composer: MinimalComposer }} />The Welcome slot is always mounted and controls its own visibility. Use useThread to replicate the default "show only when the thread is empty" behaviour:
import { TimbalChat, useThread, useThreadRuntime, type ThreadWelcomeProps } from "@timbal-ai/timbal-react";
const BrandedWelcome = ({ suggestions }: ThreadWelcomeProps) => {
const isEmpty = useThread((s) => s.isEmpty);
const runtime = useThreadRuntime();
if (!isEmpty) return null;
return (
<div className="flex flex-col items-center justify-center h-full gap-4">
<img src="/logo.svg" className="h-12" />
<h2 className="text-xl font-semibold">Welcome to Acme AI</h2>
<div className="flex gap-2 flex-wrap justify-center">
{suggestions?.map((s) => (
<button
key={s.title}
onClick={() => runtime.append({ role: "user", content: [{ type: "text", text: s.title }] })}
className="border rounded-full px-4 py-1.5 text-sm hover:bg-muted"
>
{s.title}
</button>
))}
</div>
</div>
);
};
<TimbalChat
workforceId="..."
suggestions={[{ title: "Get started" }, { title: "Show me an example" }]}
components={{ Welcome: BrandedWelcome }}
/>Override any combination — slots are independent of each other:
<TimbalChat
workforceId="..."
components={{
UserMessage: CompactUserMessage,
Composer: MinimalComposer,
}}
/>These are re-exported from @assistant-ui/react for use inside custom slot components:
| Export | Use inside |
|---|---|
ThreadPrimitive |
Any slot |
MessagePrimitive |
UserMessage, AssistantMessage, EditComposer |
ComposerPrimitive |
Composer, EditComposer |
ActionBarPrimitive |
UserMessage, AssistantMessage |
useThread |
Any slot — subscribe to thread state (e.g. isRunning, isEmpty) |
useThreadRuntime |
Any slot — call actions (e.g. runtime.append(...)) |
useMessageRuntime |
UserMessage, AssistantMessage — edit, reload, branch |
useComposerRuntime |
Composer, EditComposer — access composer state |
Agents can return structured JSON artifacts — charts, tables, choice widgets, and interactive UI — instead of plain text. The chat UI renders them automatically from tool results or inline ```timbal-artifact fences.
Import the ready-made instruction block and append it to your workforce system prompt (or blueprint tool-result docs):
import { ARTIFACT_AGENT_INSTRUCTIONS } from "@timbal-ai/timbal-react";
const systemPrompt = `${basePrompt}\n\n${ARTIFACT_AGENT_INSTRUCTIONS}`;ARTIFACT_AGENT_INSTRUCTIONS documents every built-in type (chart, table, question, html, json, ui) and the full interactive ui node palette (hover tooltips, buttons, toggles, sliders, drag).
ui artifacts can fire { kind: "emit" } actions (e.g. after a slider commit or drag). Handle them with onArtifactEvent on Thread or TimbalChat:
<TimbalChat
workforceId="your-workforce-id"
onArtifactEvent={(event) => {
console.log(event.name, event.payload);
// e.g. refetch data, update local UI, call your API
}}
/>When using Thread directly:
<TimbalRuntimeProvider workforceId="your-workforce-id">
<Thread
onArtifactEvent={(event) => console.log(event.name, event.payload)}
/>
</TimbalRuntimeProvider>Built-in { kind: "message" } actions already append a user message — you only need onArtifactEvent for host-side logic beyond that.
Register extra type values or override defaults:
<TimbalChat
workforceId="..."
artifacts={{
renderers: {
"my:widget": MyWidgetRenderer,
},
}}
/>Extend the interactive palette with host-registered custom nodes:
import {
UiCustomNodeRegistryProvider,
TimbalRuntimeProvider,
Thread,
} from "@timbal-ai/timbal-react";
<UiCustomNodeRegistryProvider renderers={{ "price-card": PriceCard }}>
<TimbalRuntimeProvider workforceId="...">
<Thread />
</TimbalRuntimeProvider>
</UiCustomNodeRegistryProvider>TimbalChat accepts all TimbalRuntimeProvider props plus all Thread props.
| Prop | Type | Default | Description |
|---|---|---|---|
workforceId |
string |
required | ID of the workforce to stream from |
baseUrl |
string |
"/api" |
Base URL for API calls. Posts to {baseUrl}/workforce/{workforceId}/stream |
fetch |
(url, options?) => Promise<Response> |
authFetch |
Custom fetch. Defaults to the built-in auth-aware fetch (Bearer token + auto-refresh) |
attachments |
boolean | { uploadUrl?, accept? } | AttachmentAdapter | null |
off | true or a config object enables the built-in upload adapter; null disables; omitted = off |
attachmentsUploadUrl |
string |
— | Shorthand: enables the default adapter with a custom upload URL |
attachmentsAccept |
string |
— | Shorthand: MIME accept for the default adapter |
debug |
boolean |
false |
Log every parsed SSE event to the console with a [timbal] prefix |
welcome.heading |
string |
"How can I help you today?" |
Welcome screen heading |
welcome.subheading |
string |
"Send a message to start a conversation." |
Welcome screen subheading |
suggestions |
{ title: string; description?: string }[] |
— | Suggestion chips on the welcome screen |
composerPlaceholder |
string |
"Send a message..." |
Composer input placeholder |
components |
ThreadComponents |
— | Override individual UI slots |
onArtifactEvent |
(event: UiEventEnvelope) => void |
— | Called when a ui artifact fires an emit action |
maxWidth |
string |
"44rem" |
Max width of the message column |
className |
string |
— | Extra classes on the root element |
Same as TimbalChat minus workforceId, baseUrl, and fetch (those live on TimbalRuntimeProvider).
| Prop | Type | Default | Description |
|---|---|---|---|
workforceId |
string |
required | ID of the workforce to stream from |
baseUrl |
string |
"/api" |
Base URL for API calls |
fetch |
(url, options?) => Promise<Response> |
authFetch |
Custom fetch function |
attachments |
same as TimbalChat |
off | Enable uploads on the runtime (usually set on TimbalChat instead) |
attachmentsUploadUrl |
string |
— | Shorthand upload URL for the default adapter |
attachmentsAccept |
string |
— | Shorthand MIME accept for the default adapter |
debug |
boolean |
false |
SSE debug logging (see above) |
Extends all TimbalChat props except workforceId is optional.
| Prop | Type | Default | Description |
|---|---|---|---|
workforceId |
string |
auto from API | When set, skips fetching and hides the built-in selector |
brand |
ReactNode |
— | Logo or title at the start of the header |
headerActions |
ReactNode |
— | Trailing header content (logout, theme toggle, etc.) |
hideWorkforceSelector |
boolean |
false |
Hide the built-in <select> even when multiple agents exist |
className |
string |
— | Classes on the outer h-screen flex container |
headerClassName |
string |
— | Classes on the header bar |
The package includes an optional session/auth system backed by localStorage tokens. The API is expected to expose /api/auth/login, /api/auth/logout, and /api/auth/refresh.
Auth is opt-in — it only activates when VITE_TIMBAL_PROJECT_ID is set in your environment.
Wrap your app with SessionProvider and protect routes with AuthGuard:
// src/App.tsx
import { SessionProvider, AuthGuard, TooltipProvider } from "@timbal-ai/timbal-react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
const isAuthEnabled = !!import.meta.env.VITE_TIMBAL_PROJECT_ID;
export default function App() {
return (
<SessionProvider enabled={isAuthEnabled}>
<TooltipProvider>
<BrowserRouter>
<AuthGuard requireAuth enabled={isAuthEnabled}>
<Routes>
<Route path="/" element={<Home />} />
</Routes>
</AuthGuard>
</BrowserRouter>
</TooltipProvider>
</SessionProvider>
);
}When enabled is false, both SessionProvider and AuthGuard are transparent — no redirects, no API calls.
When the app runs inside an iframe, SessionProvider detects embedding and skips the normal cookie refresh flow. Instead:
- The child posts
{ type: "timbal:request-session" }towindow.parent. - The parent responds with
{ type: "timbal:auth", token: "<access>", refreshToken?: "<refresh>" }. - Tokens are stored in localStorage and
fetchCurrentUser()runs as usual.
useSession() exposes isEmbedded: boolean so you can adjust UI (e.g. hide logout redirects that assume a top-level window).
// Parent page
iframe.contentWindow?.postMessage(
{ type: "timbal:auth", token: accessToken, refreshToken },
"*",
);
window.addEventListener("message", (e) => {
if (e.data?.type === "timbal:request-session") {
// inject tokens as above
}
});Access the current session anywhere inside SessionProvider:
import { useSession } from "@timbal-ai/timbal-react";
function Header() {
const { user, isAuthenticated, isEmbedded, loading, logout } = useSession();
if (loading) return null;
return (
<header>
{isAuthenticated ? (
<>
<span>{user?.email}</span>
<button onClick={logout}>Log out</button>
</>
) : (
<a href="/login">Log in</a>
)}
</header>
);
}A drop-in replacement for fetch that attaches the Bearer token from localStorage and auto-refreshes on 401. It's also the default fetch used by TimbalRuntimeProvider, so you only need to import it directly for your own API calls (e.g. loading workforce lists):
import { authFetch } from "@timbal-ai/timbal-react";
const res = await authFetch("/api/workforce");
if (res.ok) {
const agents = await res.json();
}| Component | Prop | Type | Default | Description |
|---|---|---|---|---|
SessionProvider |
enabled |
boolean |
true |
When false, session is always null and no API calls are made |
AuthGuard |
requireAuth |
boolean |
false |
Redirect to login if not authenticated |
AuthGuard |
enabled |
boolean |
true |
When false, renders children unconditionally |
| Export | Description |
|---|---|
TimbalStudioShell |
Floating sidebar + top bar + full-height TimbalChat. Most opinionated layout |
StudioSidebar |
Floating workforce sidebar — collapses, mobile drawer, runtime portal anchor |
ModeToggle |
Sun/moon theme toggle styled for the studio top bar |
TimbalMark |
Liquid-metal brand mark — drop-in welcome icon |
StudioWelcome |
Welcome screen with TimbalMark + staggered intro animation |
TimbalChatShell |
Header + workforce picker + full-height TimbalChat |
Thread |
Full chat UI — messages, composer, attachments, action bar |
Composer |
Standalone composer bar (for custom thread layouts) |
Suggestions |
Suggestion chip grid/row; use with useResolvedSuggestions |
WorkforceSelector |
Styled native <select> for agent switching |
MarkdownText |
Markdown renderer with GFM, math (KaTeX), and syntax highlighting |
ToolFallback |
Animated "Using tool: …" indicator shown while a tool runs |
ARTIFACT_AGENT_INSTRUCTIONS |
Markdown block to paste into agent system prompts |
ArtifactRegistryProvider |
Scope custom artifact renderers |
UiEventProvider |
Low-level provider for ui artifact emit actions |
UiCustomNodeRegistryProvider |
Register { kind: "custom" } node renderers |
ArtifactView |
Render a single artifact object |
parseArtifactFromToolResult |
Parse tool output into an artifact |
TooltipIconButton |
Icon button with a tooltip |
| Export | Description |
|---|---|
useWorkforces |
Fetch {baseUrl}/workforce and track selection |
useTimbalStream |
Low-level SSE chat state without <Thread> |
useTimbalRuntime |
Access runtime context inside custom providers |
useResolvedSuggestions |
Resolve static/async SuggestionsSource to an array |
useOptionalSession |
Same as useSession but returns null when no SessionProvider is mounted |
Re-exported Radix UI wrappers pre-styled to match the Timbal design system:
Button · Tooltip · TooltipTrigger · TooltipContent · TooltipProvider · Avatar · AvatarImage · AvatarFallback · Dialog · DialogContent · DialogTitle · DialogTrigger · Shimmer
App shell with optional auth, using TimbalChatShell (agent list + chat in one component):
// src/App.tsx
import {
SessionProvider,
AuthGuard,
TooltipProvider,
TimbalChatShell,
} from "@timbal-ai/timbal-react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
import Home from "./pages/Home";
const isAuthEnabled = !!import.meta.env.VITE_TIMBAL_PROJECT_ID;
export default function App() {
return (
<SessionProvider enabled={isAuthEnabled}>
<TooltipProvider>
<BrowserRouter>
<AuthGuard requireAuth enabled={isAuthEnabled}>
<Routes>
<Route path="/" element={<Home />} />
</Routes>
</AuthGuard>
</BrowserRouter>
</TooltipProvider>
</SessionProvider>
);
}// src/pages/Home.tsx
import { TimbalChatShell, Button, useSession } from "@timbal-ai/timbal-react";
import { LogOut } from "lucide-react";
const isAuthEnabled = !!import.meta.env.VITE_TIMBAL_PROJECT_ID;
export default function Home() {
const { logout, isAuthenticated } = useSession();
return (
<TimbalChatShell
brand={<span className="text-sm font-semibold">My App</span>}
headerActions={
isAuthEnabled && isAuthenticated ? (
<Button variant="ghost" size="icon" onClick={logout} aria-label="Log out">
<LogOut className="size-4" />
</Button>
) : null
}
welcome={{ heading: "How can I help you today?" }}
suggestions={[
{ title: "Summarize this week", description: "Recent activity at a glance" },
{ title: "What can you help with?" },
]}
attachments
debug={import.meta.env.DEV}
/>
);
}For a fully custom header, combine useWorkforces with TimbalChat instead of TimbalChatShell (see Workforce list hook).
0.5.0 slims the public API to the surface that blueprint apps actually use. Every feature still works — only the export list changed.
All sizing and class composites moved to internal modules. Override the CSS variables in your own :root / .dark blocks instead of importing helper strings:
:root {
--studio-sidebar-width: 15rem;
--studio-topbar-height: 3.25rem;
}For colours, override the existing semantic tokens (--background, --foreground, --composer-bg, --bubble-user, --playground-from/via/to, …). See src/styles.css for the full list.
The following symbols are no longer exported. Most have no replacement because they were never meant to be public; for the rest, prefer the high-level shells or CSS-variable overrides:
- All
STUDIO_*layout constants (STUDIO_SIDEBAR_WIDTH,STUDIO_INSET_LEFT,STUDIO_SIDEBAR_COLLAPSED_STORAGE_KEY,STUDIO_SIDEBAR_PX_*, …) — override the matching--studio-*CSS variables instead. - All
studio*Class/studioChromeShellStylehelpers — re-create the look with normal Tailwind classes against semantic tokens (bg-elevated-from,border-border,shadow-card, …). - All
TIMBAL_V2_*button token records andTimbalV2Button— use the standardButtonexport from this package; it covers the same variants. StudioSidebarPanel,StudioSidebarHeader/Nav/Footer/Entries/Backdrop/Tooltip/RuntimePortal/EntryMotion,StudioSidebarContext,useStudioSidebarLayout,useStudioSidebarCollapsed,useSidebarCollapsePhase,workforceItemId/Label/Initial— useStudioSidebarorTimbalStudioShelldirectly.runThemeSanityCheck—<Thread>already schedules the dev-only check.SyntaxHighlighter,UserMessageAttachments,ComposerAttachments,ComposerAddAttachment,MessagePartPrimitive,ActionBarMorePrimitive,ErrorPrimitive,useAuiState,buttonVariants— internal composer/markdown details. Override theComposer/AssistantMessageslot via thecomponentsprop if you need a custom layout.
Everything else (the three shells, primitives, hooks, auth, artifact API, design-token CSS variables) is unchanged.
An offline Vite app lives in examples/mock-ui. It uses a scripted mock fetch (no API keys) and includes a component gallery for artifacts. See that folder’s README for run instructions.
Install via a local path reference:
{
"dependencies": {
"@timbal-ai/timbal-react": "file:../../timbal-react"
}
}Adjust the relative path to where timbal-react lives on your machine.
After editing source files, rebuild:
cd timbal-react
bun run build # one-off build
bun run build:watch # rebuild on every changeVite picks up the new dist/ automatically via HMR — no reinstall needed.