@timbal-ai/timbal-react 0.2.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,6 +29,17 @@ The package ships pre-built Tailwind class names. Add this `@source` line to you
29
29
 
30
30
  > Adjust the path if your CSS file lives at a different depth relative to `node_modules`.
31
31
 
32
+ ### Companion stylesheet (optional)
33
+
34
+ For blueprint-style polish (welcome animations, suggestion chips, tool indicators), import the shipped companion CSS once. Requires Tailwind v4 design tokens (`--primary`, etc.) in your app:
35
+
36
+ ```ts
37
+ // src/main.tsx
38
+ import "@timbal-ai/timbal-react/styles.css";
39
+ ```
40
+
41
+ You can omit this entirely and style components yourself — the package does not bundle mandatory CSS beyond what Tailwind scans from `dist/`.
42
+
32
43
  ### CSS imports
33
44
 
34
45
  Import these stylesheets once in your app entry:
@@ -78,6 +89,20 @@ export default function App() {
78
89
  />
79
90
  ```
80
91
 
92
+ Suggestions also accept a function (sync or async) for per-user or server-driven chips:
93
+
94
+ ```tsx
95
+ <TimbalChat
96
+ workforceId="your-workforce-id"
97
+ suggestions={async () => {
98
+ const res = await authFetch("/api/suggestions");
99
+ return res.json(); // ThreadSuggestion[]
100
+ }}
101
+ />
102
+ ```
103
+
104
+ Each chip supports `icon`, `description`, and `prompt` (sent instead of `title` when clicked).
105
+
81
106
  ### Placeholder and width
82
107
 
83
108
  ```tsx
@@ -104,6 +129,66 @@ const [workforceId, setWorkforceId] = useState("agent-a");
104
129
  <TimbalChat workforceId={workforceId} key={workforceId} />
105
130
  ```
106
131
 
132
+ ### Drop-in shell (header + agent picker)
133
+
134
+ `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:
135
+
136
+ ```tsx
137
+ import { TimbalChatShell, Button, useSession } from "@timbal-ai/timbal-react";
138
+
139
+ export default function App() {
140
+ const { logout, isAuthenticated } = useSession();
141
+
142
+ return (
143
+ <TimbalChatShell
144
+ brand={<span className="font-semibold">Acme AI</span>}
145
+ headerActions={
146
+ isAuthenticated ? (
147
+ <Button variant="ghost" size="sm" onClick={logout}>
148
+ Log out
149
+ </Button>
150
+ ) : null
151
+ }
152
+ welcome={{ heading: "How can I help you today?" }}
153
+ suggestions={[{ title: "Get started" }]}
154
+ />
155
+ );
156
+ }
157
+ ```
158
+
159
+ Pass `workforceId` to lock the agent and hide the built-in selector. Use `hideWorkforceSelector` when you render your own picker.
160
+
161
+ ### Workforce list hook
162
+
163
+ For custom layouts (sidebar tree, command palette), use `useWorkforces` with the optional `WorkforceSelector`:
164
+
165
+ ```tsx
166
+ import {
167
+ TimbalChat,
168
+ useWorkforces,
169
+ WorkforceSelector,
170
+ } from "@timbal-ai/timbal-react";
171
+
172
+ function ChatWithPicker() {
173
+ const { workforces, selectedId, setSelectedId, isLoading } = useWorkforces();
174
+
175
+ if (isLoading) return <div>Loading agents…</div>;
176
+
177
+ return (
178
+ <div className="flex h-screen flex-col">
179
+ <WorkforceSelector
180
+ workforces={workforces}
181
+ value={selectedId}
182
+ onChange={setSelectedId}
183
+ />
184
+ <TimbalChat workforceId={selectedId} key={selectedId} className="min-h-0 flex-1" />
185
+ </div>
186
+ );
187
+ }
188
+ ```
189
+
190
+ `useWorkforces` accepts `baseUrl`, `fetch`, and `pickInitial` (custom resolver for the default selection). It returns `selected`, `error`, and `refresh()` as well.
191
+
107
192
  ---
108
193
 
109
194
  ## Splitting the runtime and UI
@@ -138,6 +223,52 @@ Useful when your API is mounted at a subpath (e.g. behind a reverse proxy):
138
223
  </TimbalRuntimeProvider>
139
224
  ```
140
225
 
226
+ ### Attachments
227
+
228
+ Attachments are **opt-in**. Pass `attachments` to enable the composer `+` button, drag-and-drop, and multimodal prompts:
229
+
230
+ ```tsx
231
+ <TimbalChat workforceId="your-workforce-id" attachments />
232
+ ```
233
+
234
+ 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.
235
+
236
+ 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.
237
+
238
+ #### Variants
239
+
240
+ ```tsx
241
+ // Default upload adapter
242
+ <TimbalChat workforceId="..." attachments />
243
+
244
+ // Custom endpoint or MIME whitelist
245
+ <TimbalChat
246
+ workforceId="..."
247
+ attachments={{ uploadUrl: "/api/uploads", accept: "image/*,application/pdf" }}
248
+ />
249
+
250
+ // Fully custom adapter (e.g. presigned S3)
251
+ <TimbalChat workforceId="..." attachments={myAdapter} />
252
+
253
+ // Explicitly off (default when prop is omitted)
254
+ <TimbalChat workforceId="..." attachments={null} />
255
+ ```
256
+
257
+ #### Power-user exports
258
+
259
+ ```tsx
260
+ import {
261
+ createDefaultAttachmentAdapter,
262
+ createUploadAttachmentAdapter,
263
+ resolveAttachmentAdapter,
264
+ parseSSELine,
265
+ AssistantRuntimeProvider,
266
+ useTimbalStream,
267
+ } from "@timbal-ai/timbal-react";
268
+ ```
269
+
270
+ `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>`.
271
+
141
272
  ### Custom fetch function
142
273
 
143
274
  Pass your own `fetch` to add headers, inject tokens, or proxy requests:
@@ -168,8 +299,9 @@ Use the `components` prop on `TimbalChat` or `Thread` to replace any part of the
168
299
  | `UserMessage` | none | built-in user bubble |
169
300
  | `AssistantMessage` | none | built-in assistant bubble |
170
301
  | `EditComposer` | none | built-in inline edit composer |
171
- | `Composer` | `placeholder` | built-in composer bar |
172
- | `Welcome` | `config`, `suggestions` | built-in welcome screen |
302
+ | `Composer` | `placeholder` (+ full `ComposerProps`) | built-in composer bar |
303
+ | `Welcome` | `config`, `suggestions`, `Suggestions` | built-in welcome screen |
304
+ | `Suggestions` | `suggestions` | built-in suggestion chips |
173
305
  | `ScrollToBottom` | none | built-in scroll button |
174
306
 
175
307
  Custom slot components read their data via hooks — no props are passed automatically except where noted above.
@@ -281,6 +413,81 @@ These are re-exported from `@assistant-ui/react` for use inside custom slot comp
281
413
 
282
414
  ---
283
415
 
416
+ ## Artifacts
417
+
418
+ 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.
419
+
420
+ ### Tell the agent about the schema
421
+
422
+ Import the ready-made instruction block and append it to your workforce system prompt (or blueprint tool-result docs):
423
+
424
+ ```ts
425
+ import { ARTIFACT_AGENT_INSTRUCTIONS } from "@timbal-ai/timbal-react";
426
+
427
+ const systemPrompt = `${basePrompt}\n\n${ARTIFACT_AGENT_INSTRUCTIONS}`;
428
+ ```
429
+
430
+ `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).
431
+
432
+ ### Subscribe to interactive events
433
+
434
+ `ui` artifacts can fire `{ kind: "emit" }` actions (e.g. after a slider commit or drag). Handle them with `onArtifactEvent` on `Thread` or `TimbalChat`:
435
+
436
+ ```tsx
437
+ <TimbalChat
438
+ workforceId="your-workforce-id"
439
+ onArtifactEvent={(event) => {
440
+ console.log(event.name, event.payload);
441
+ // e.g. refetch data, update local UI, call your API
442
+ }}
443
+ />
444
+ ```
445
+
446
+ When using `Thread` directly:
447
+
448
+ ```tsx
449
+ <TimbalRuntimeProvider workforceId="your-workforce-id">
450
+ <Thread
451
+ onArtifactEvent={(event) => console.log(event.name, event.payload)}
452
+ />
453
+ </TimbalRuntimeProvider>
454
+ ```
455
+
456
+ Built-in `{ kind: "message" }` actions already append a user message — you only need `onArtifactEvent` for host-side logic beyond that.
457
+
458
+ ### Custom artifact renderers
459
+
460
+ Register extra `type` values or override defaults:
461
+
462
+ ```tsx
463
+ <TimbalChat
464
+ workforceId="..."
465
+ artifacts={{
466
+ renderers: {
467
+ "my:widget": MyWidgetRenderer,
468
+ },
469
+ }}
470
+ />
471
+ ```
472
+
473
+ Extend the interactive palette with host-registered `custom` nodes:
474
+
475
+ ```tsx
476
+ import {
477
+ UiCustomNodeRegistryProvider,
478
+ TimbalRuntimeProvider,
479
+ Thread,
480
+ } from "@timbal-ai/timbal-react";
481
+
482
+ <UiCustomNodeRegistryProvider renderers={{ "price-card": PriceCard }}>
483
+ <TimbalRuntimeProvider workforceId="...">
484
+ <Thread />
485
+ </TimbalRuntimeProvider>
486
+ </UiCustomNodeRegistryProvider>
487
+ ```
488
+
489
+ ---
490
+
284
491
  ## API reference
285
492
 
286
493
  ### `TimbalChat` props
@@ -292,11 +499,16 @@ These are re-exported from `@assistant-ui/react` for use inside custom slot comp
292
499
  | `workforceId` | `string` | **required** | ID of the workforce to stream from |
293
500
  | `baseUrl` | `string` | `"/api"` | Base URL for API calls. Posts to `{baseUrl}/workforce/{workforceId}/stream` |
294
501
  | `fetch` | `(url, options?) => Promise<Response>` | `authFetch` | Custom fetch. Defaults to the built-in auth-aware fetch (Bearer token + auto-refresh) |
502
+ | `attachments` | `boolean \| { uploadUrl?, accept? } \| AttachmentAdapter \| null` | off | `true` or a config object enables the built-in upload adapter; `null` disables; omitted = off |
503
+ | `attachmentsUploadUrl` | `string` | — | Shorthand: enables the default adapter with a custom upload URL |
504
+ | `attachmentsAccept` | `string` | — | Shorthand: MIME `accept` for the default adapter |
505
+ | `debug` | `boolean` | `false` | Log every parsed SSE event to the console with a `[timbal]` prefix |
295
506
  | `welcome.heading` | `string` | `"How can I help you today?"` | Welcome screen heading |
296
507
  | `welcome.subheading` | `string` | `"Send a message to start a conversation."` | Welcome screen subheading |
297
508
  | `suggestions` | `{ title: string; description?: string }[]` | — | Suggestion chips on the welcome screen |
298
509
  | `composerPlaceholder` | `string` | `"Send a message..."` | Composer input placeholder |
299
510
  | `components` | `ThreadComponents` | — | Override individual UI slots |
511
+ | `onArtifactEvent` | `(event: UiEventEnvelope) => void` | — | Called when a `ui` artifact fires an `emit` action |
300
512
  | `maxWidth` | `string` | `"44rem"` | Max width of the message column |
301
513
  | `className` | `string` | — | Extra classes on the root element |
302
514
 
@@ -311,6 +523,23 @@ Same as `TimbalChat` minus `workforceId`, `baseUrl`, and `fetch` (those live on
311
523
  | `workforceId` | `string` | **required** | ID of the workforce to stream from |
312
524
  | `baseUrl` | `string` | `"/api"` | Base URL for API calls |
313
525
  | `fetch` | `(url, options?) => Promise<Response>` | `authFetch` | Custom fetch function |
526
+ | `attachments` | same as `TimbalChat` | off | Enable uploads on the runtime (usually set on `TimbalChat` instead) |
527
+ | `attachmentsUploadUrl` | `string` | — | Shorthand upload URL for the default adapter |
528
+ | `attachmentsAccept` | `string` | — | Shorthand MIME accept for the default adapter |
529
+ | `debug` | `boolean` | `false` | SSE debug logging (see above) |
530
+
531
+ ### `TimbalChatShell` props
532
+
533
+ Extends all `TimbalChat` props except `workforceId` is optional.
534
+
535
+ | Prop | Type | Default | Description |
536
+ |---|---|---|---|
537
+ | `workforceId` | `string` | auto from API | When set, skips fetching and hides the built-in selector |
538
+ | `brand` | `ReactNode` | — | Logo or title at the start of the header |
539
+ | `headerActions` | `ReactNode` | — | Trailing header content (logout, theme toggle, etc.) |
540
+ | `hideWorkforceSelector` | `boolean` | `false` | Hide the built-in `<select>` even when multiple agents exist |
541
+ | `className` | `string` | — | Classes on the outer `h-screen` flex container |
542
+ | `headerClassName` | `string` | — | Classes on the header bar |
314
543
 
315
544
  ---
316
545
 
@@ -351,6 +580,30 @@ export default function App() {
351
580
 
352
581
  When `enabled` is `false`, both `SessionProvider` and `AuthGuard` are transparent — no redirects, no API calls.
353
582
 
583
+ ### Embedding in an iframe
584
+
585
+ When the app runs inside an iframe, `SessionProvider` detects embedding and skips the normal cookie refresh flow. Instead:
586
+
587
+ 1. The child posts `{ type: "timbal:request-session" }` to `window.parent`.
588
+ 2. The parent responds with `{ type: "timbal:auth", token: "<access>", refreshToken?: "<refresh>" }`.
589
+ 3. Tokens are stored in localStorage and `fetchCurrentUser()` runs as usual.
590
+
591
+ `useSession()` exposes `isEmbedded: boolean` so you can adjust UI (e.g. hide logout redirects that assume a top-level window).
592
+
593
+ ```tsx
594
+ // Parent page
595
+ iframe.contentWindow?.postMessage(
596
+ { type: "timbal:auth", token: accessToken, refreshToken },
597
+ "*",
598
+ );
599
+
600
+ window.addEventListener("message", (e) => {
601
+ if (e.data?.type === "timbal:request-session") {
602
+ // inject tokens as above
603
+ }
604
+ });
605
+ ```
606
+
354
607
  ### `useSession` hook
355
608
 
356
609
  Access the current session anywhere inside `SessionProvider`:
@@ -359,7 +612,7 @@ Access the current session anywhere inside `SessionProvider`:
359
612
  import { useSession } from "@timbal-ai/timbal-react";
360
613
 
361
614
  function Header() {
362
- const { user, isAuthenticated, loading, logout } = useSession();
615
+ const { user, isAuthenticated, isEmbedded, loading, logout } = useSession();
363
616
  if (loading) return null;
364
617
  return (
365
618
  <header>
@@ -405,15 +658,34 @@ if (res.ok) {
405
658
 
406
659
  | Export | Description |
407
660
  |---|---|
661
+ | `TimbalChatShell` | Header + workforce picker + full-height `TimbalChat` |
408
662
  | `Thread` | Full chat UI — messages, composer, attachments, action bar |
663
+ | `Composer` | Standalone composer bar (for custom thread layouts) |
664
+ | `Suggestions` | Suggestion chip grid/row; use with `useResolvedSuggestions` |
665
+ | `WorkforceSelector` | Styled native `<select>` for agent switching |
409
666
  | `MarkdownText` | Markdown renderer with GFM, math (KaTeX), and syntax highlighting |
410
667
  | `ToolFallback` | Animated "Using tool: …" indicator shown while a tool runs |
668
+ | `ARTIFACT_AGENT_INSTRUCTIONS` | Markdown block to paste into agent system prompts |
669
+ | `ArtifactRegistryProvider` | Scope custom artifact renderers |
670
+ | `UiEventProvider` | Low-level provider for `ui` artifact `emit` actions |
671
+ | `UiCustomNodeRegistryProvider` | Register `{ kind: "custom" }` node renderers |
672
+ | `ArtifactView` | Render a single artifact object |
673
+ | `parseArtifactFromToolResult` | Parse tool output into an artifact |
411
674
  | `SyntaxHighlighter` | Shiki-based code highlighter (vitesse-dark / vitesse-light themes) |
412
675
  | `UserMessageAttachments` | Attachment thumbnails in user messages |
413
676
  | `ComposerAttachments` | Attachment previews inside the composer |
414
677
  | `ComposerAddAttachment` | "+" button to add attachments |
415
678
  | `TooltipIconButton` | Icon button with a tooltip |
416
679
 
680
+ ### Hooks
681
+
682
+ | Export | Description |
683
+ |---|---|
684
+ | `useWorkforces` | Fetch `{baseUrl}/workforce` and track selection |
685
+ | `useTimbalStream` | Low-level SSE chat state without `<Thread>` |
686
+ | `useTimbalRuntime` | Access runtime context inside custom providers |
687
+ | `useResolvedSuggestions` | Resolve static/async `SuggestionsSource` to an array |
688
+
417
689
  ### UI primitives
418
690
 
419
691
  Re-exported Radix UI wrappers pre-styled to match the Timbal design system:
@@ -424,62 +696,72 @@ Re-exported Radix UI wrappers pre-styled to match the Timbal design system:
424
696
 
425
697
  ## Full example
426
698
 
427
- A complete page with agent switching, auth, and a custom header:
699
+ App shell with optional auth, using `TimbalChatShell` (agent list + chat in one component):
700
+
701
+ ```tsx
702
+ // src/App.tsx
703
+ import {
704
+ SessionProvider,
705
+ AuthGuard,
706
+ TooltipProvider,
707
+ TimbalChatShell,
708
+ } from "@timbal-ai/timbal-react";
709
+ import { BrowserRouter, Routes, Route } from "react-router-dom";
710
+ import Home from "./pages/Home";
711
+
712
+ const isAuthEnabled = !!import.meta.env.VITE_TIMBAL_PROJECT_ID;
713
+
714
+ export default function App() {
715
+ return (
716
+ <SessionProvider enabled={isAuthEnabled}>
717
+ <TooltipProvider>
718
+ <BrowserRouter>
719
+ <AuthGuard requireAuth enabled={isAuthEnabled}>
720
+ <Routes>
721
+ <Route path="/" element={<Home />} />
722
+ </Routes>
723
+ </AuthGuard>
724
+ </BrowserRouter>
725
+ </TooltipProvider>
726
+ </SessionProvider>
727
+ );
728
+ }
729
+ ```
428
730
 
429
731
  ```tsx
430
732
  // src/pages/Home.tsx
431
- import { useEffect, useState } from "react";
432
- import type { WorkforceItem } from "@timbal-ai/timbal-sdk";
433
- import { TimbalChat, Button, authFetch, useSession } from "@timbal-ai/timbal-react";
733
+ import { TimbalChatShell, Button, useSession } from "@timbal-ai/timbal-react";
434
734
  import { LogOut } from "lucide-react";
435
735
 
436
736
  const isAuthEnabled = !!import.meta.env.VITE_TIMBAL_PROJECT_ID;
437
737
 
438
738
  export default function Home() {
439
- const { logout } = useSession();
440
- const [workforces, setWorkforces] = useState<WorkforceItem[]>([]);
441
- const [selectedId, setSelectedId] = useState("");
442
-
443
- useEffect(() => {
444
- authFetch("/api/workforce")
445
- .then((r) => r.json())
446
- .then((data: WorkforceItem[]) => {
447
- setWorkforces(data);
448
- const agent = data.find((w) => w.type === "agent") ?? data[0];
449
- if (agent) setSelectedId(agent.id ?? agent.name ?? "");
450
- })
451
- .catch(() => {});
452
- }, []);
739
+ const { logout, isAuthenticated } = useSession();
453
740
 
454
741
  return (
455
- <div style={{ display: "flex", flexDirection: "column", height: "100vh" }}>
456
- <header style={{ display: "flex", justifyContent: "space-between", padding: "0.5rem 1.25rem" }}>
457
- <select value={selectedId} onChange={(e) => setSelectedId(e.target.value)}>
458
- {workforces.map((w) => (
459
- <option key={w.id ?? w.name} value={w.id ?? w.name ?? ""}>
460
- {w.name}
461
- </option>
462
- ))}
463
- </select>
464
-
465
- {isAuthEnabled && (
466
- <Button variant="ghost" size="icon" onClick={logout}>
467
- <LogOut />
742
+ <TimbalChatShell
743
+ brand={<span className="text-sm font-semibold">My App</span>}
744
+ headerActions={
745
+ isAuthEnabled && isAuthenticated ? (
746
+ <Button variant="ghost" size="icon" onClick={logout} aria-label="Log out">
747
+ <LogOut className="size-4" />
468
748
  </Button>
469
- )}
470
- </header>
471
-
472
- <TimbalChat
473
- workforceId={selectedId}
474
- key={selectedId}
475
- className="flex-1 min-h-0"
476
- welcome={{ heading: "How can I help you today?" }}
477
- />
478
- </div>
749
+ ) : null
750
+ }
751
+ welcome={{ heading: "How can I help you today?" }}
752
+ suggestions={[
753
+ { title: "Summarize this week", description: "Recent activity at a glance" },
754
+ { title: "What can you help with?" },
755
+ ]}
756
+ attachments
757
+ debug={import.meta.env.DEV}
758
+ />
479
759
  );
480
760
  }
481
761
  ```
482
762
 
763
+ For a fully custom header, combine `useWorkforces` with `TimbalChat` instead of `TimbalChatShell` (see [Workforce list hook](#workforce-list-hook)).
764
+
483
765
  ---
484
766
 
485
767
  ## Local development