@agno-hq/chat-react 0.1.1 → 0.3.1

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
@@ -1,7 +1,17 @@
1
1
  # @agno-hq/chat-react
2
2
 
3
- Headless React hook (`useAgnoChat`) plus drop-in UI components for streaming **any
4
- Agno agent, team, or workflow** from an [AgentOS](https://docs.agno.com) backend.
3
+ Composable, embeddable React components for [AgentOS](https://docs.agno.com)
4
+ a headless hook, a provider for building your own layout, and drop-in embeds for
5
+ streaming **any Agno agent, team, or workflow**.
6
+
7
+ | Subpath | Contents |
8
+ | --- | --- |
9
+ | `@agno-hq/chat-react` | Everything below. |
10
+ | `@agno-hq/chat-react/core` | `AgnoClient`, run streaming, event helpers, types. No React rendering. |
11
+ | `@agno-hq/chat-react/chat` | `useAgnoChat`, `ChatProvider`, `AgnoChat`, and every building block. |
12
+ | `@agno-hq/chat-react/styles.css` | Tokens + component styles. |
13
+ | `@agno-hq/chat-react/tokens.css` | CSS variables only. |
14
+ | `@agno-hq/chat-react/fonts.css` | Inter + DM Mono (optional). |
5
15
 
6
16
  It speaks the AgentOS HTTP run protocol directly: it POSTs to the run endpoint,
7
17
  parses the streamed events, and accumulates them into render-ready messages —
@@ -9,16 +19,69 @@ content, tool calls, reasoning, citations, media — while exposing the raw even
9
19
  feed, live status, and human-in-the-loop pauses.
10
20
 
11
21
  ```tsx
12
- import { AgnoChat } from '@agno-hq/chat-react'
22
+ import { AgnoChat } from '@agno-hq/chat-react/chat'
13
23
  import '@agno-hq/chat-react/styles.css'
24
+ import '@agno-hq/chat-react/fonts.css' // optional — Inter + DM Mono
14
25
 
15
26
  export default function Page() {
16
- return <AgnoChat baseUrl="http://localhost:7777" showEventLog allowFiles />
27
+ return <AgnoChat baseUrl="http://localhost:7777" allowFiles />
17
28
  }
18
29
  ```
19
30
 
20
- That's the zero-wiring widget: it discovers the agents/teams/workflows on your
21
- AgentOS, shows a selector, and runs a full chat. For full control, use the hook.
31
+ That's the zero-wiring embed: it discovers the agents/teams/workflows on your
32
+ AgentOS and runs a full chat. It renders **no header** and fills its container,
33
+ so it drops into a page, a card, a drawer or a modal without fighting your
34
+ layout — put your own chrome in the `header` slot.
35
+
36
+ Support chat on every page is the same component in launcher mode:
37
+
38
+ ```tsx
39
+ <AgnoChat mode="launcher" baseUrl="..." launcherLabel="Chat with us" />
40
+ ```
41
+
42
+ ### Compose it instead
43
+
44
+ `<ChatProvider>` runs the chat and shares it through context, so every component
45
+ below reads it with no props:
46
+
47
+ ```tsx
48
+ import { ChatProvider, MessageList, ChatInput } from '@agno-hq/chat-react/chat'
49
+
50
+ <ChatProvider baseUrl="http://localhost:7777" entity={agent}>
51
+ <MyOwnHeader />
52
+ <MessageList />
53
+ <ChatInput />
54
+ </ChatProvider>
55
+ ```
56
+
57
+ Reach for `useChatContext()` to drive it from your own components, or call
58
+ `useAgnoChat` directly and render nothing of ours.
59
+
60
+ ### Customize it
61
+
62
+ Three independent levers:
63
+
64
+ ```tsx
65
+ /* 1. Tokens — palette, radii and shadows from agno-os/globals.css */
66
+ :root { --color-brand-brand: 99, 102, 241; --agno-radius-lg: 1.25rem; }
67
+
68
+ /* 2. Slots — every element is addressable by name */
69
+ <AgnoChat classNames={{ root: 'rounded-2xl', textarea: 'font-mono' }} />
70
+
71
+ /* 3. Replacement — swap any piece for your own */
72
+ <ChatWindow composer={<MyComposer />} header={<MyBar />} />
73
+ ```
74
+
75
+ The built-in classes stay on the elements, so Tailwind utilities layer on top
76
+ rather than replacing them. Import only `tokens.css` (or nothing) to style from
77
+ scratch.
78
+
79
+ ### Storybook
80
+
81
+ `npm run storybook` runs the component gallery locally; every story streams
82
+ against a scripted in-memory backend, so it exercises the real hook and the real
83
+ event reducers without an AgentOS running. Pushes to `main` publish it to GitHub
84
+ Pages via `.github/workflows/storybook.yml`.
22
85
 
23
86
  ---
24
87
 
@@ -89,32 +152,330 @@ agent/team/workflow id — the same hook drives all three.
89
152
 
90
153
  ## Components
91
154
 
92
- All components are styled by `@agno-hq/chat-react/styles.css` (dark by default; add
93
- the `agno-light` class on a wrapper for light mode). Every piece is exported so
94
- you can compose your own layout.
155
+ All components are styled by `@agno-hq/chat-react/styles.css`, which carries the
156
+ Agno OS design system the same colour tokens, radii, Inter/DM Mono type scale
157
+ and chat layout as the AgentOS chat page. It is dark by default; add the
158
+ `agno-light` class on a wrapper for light mode (`agno-dark` is accepted too, for
159
+ apps that toggle both). Every piece is exported so you can compose your own
160
+ layout.
161
+
162
+ Theme it by overriding the tokens on your wrapper — they are the AgentOS ones,
163
+ as `R, G, B` triples:
164
+
165
+ ```css
166
+ .agno-root.my-brand {
167
+ --color-brand-brand: 255, 64, 23;
168
+ --color-background: 17, 17, 19;
169
+ --color-background-secondary: 39, 39, 42;
170
+ }
171
+ ```
95
172
 
96
173
  | Component | Purpose |
97
174
  |---|---|
98
- | `<AgnoChat>` | All-in-one widget: discovery, selector, chat, optional event log. |
175
+ | `<AgnoChat>` | All-in-one widget: discovery, selector, chat, optional per-run event feed. |
99
176
  | `<ChatWindow chat={chat}>` | Full chat surface built from a `useAgnoChat` result. |
177
+ | `<ChatLauncher>` | Floating support-widget shell: a corner bubble that opens any chat surface. |
178
+ | `<QuickPrompts>` | Suggested-prompt chips. |
100
179
  | `<MessageList>` | Auto-scrolling transcript with live status + footer slot. |
101
- | `<Message>` | A single message: content, tools, reasoning, media, citations. |
102
- | `<ChatInput>` | Multiline input with file attach, send, and stop. |
180
+ | `<Message>` | A single message: content, behind-the-scenes panel, media, citations, follow-ups. |
181
+ | `<BehindTheScenes>` | One collapsible timeline over a run's reasoning, tools, member turns, steps and events — doubles as the live status line. |
182
+ | `<ChatInput>` | Multiline input with file attach, send, and stop; opens up for @mentions, context chips and a custom textarea. |
103
183
  | `<ToolCalls>` | Collapsible tool-call cards (name, args, result, status). |
104
184
  | `<Reasoning>` | Collapsible reasoning-steps panel. |
105
- | `<Citations>` | References / source URLs. |
185
+ | `<Citations>` | The sources behind an answer, as linked cards. |
186
+ | `<Followups>` | "Related questions" — the follow-up prompts an agent suggests after its answer. |
106
187
  | `<Multimedia>` | Images, video, and audio attachments. |
107
188
  | `<StatusIndicator>` | Animated "what is it doing now" line. |
108
- | `<EventLog>` | Developer feed of every raw run event. |
189
+ | `<EventLog>` | Developer feed of raw run events — render it yourself from `chat.events`. |
109
190
  | `<HumanInput>` | Human-in-the-loop panel (confirm / reject / input). |
110
191
  | `<EntitySelector>` | Dropdown of agents, teams, and workflows. |
111
192
  | `<SessionList>` | Sidebar of past sessions — click to load, trash to delete. |
112
- | `<Markdown>` | The built-in lightweight Markdown renderer. |
193
+ | `<Markdown>` | Markdown, rendered by Streamdown — GFM, and repair for half-written text mid-stream. |
194
+
195
+ ### Inline, or a floating support widget
196
+
197
+ `<AgnoChat>` renders inline by default — a panel wherever you put it, sized by
198
+ `width` and `height`:
199
+
200
+ ```tsx
201
+ <AgnoChat baseUrl="http://localhost:7777" width={720} height={560} />
202
+ ```
203
+
204
+ `mode="launcher"` turns it into the support-chat layout instead: a bubble pinned
205
+ to a corner of the viewport that opens the chat. Mount it once, anywhere in your
206
+ app — it renders into `document.body`, so no wrapper can clip it.
113
207
 
114
- ### Bring your own Markdown
208
+ ```tsx
209
+ <AgnoChat
210
+ baseUrl="http://localhost:7777"
211
+ mode="launcher"
212
+ position="bottom-right"
213
+ launcherLabel="Chat with us"
214
+ width={400}
215
+ height={620}
216
+ />
217
+ ```
218
+
219
+ | Prop | Default | Description |
220
+ |---|---|---|
221
+ | `mode` | `'inline'` | `'inline'` renders in place; `'launcher'` pins a bubble to a corner. |
222
+ | `position` | `'bottom-right'` | Corner to pin to: also `bottom-left`, `top-right`, `top-left`. |
223
+ | `offset` | `24` | Distance from both edges of that corner. Number means px. |
224
+ | `panel` | `'popover'` | How far it opens: a corner popover, or `'fullscreen'` over the page. |
225
+ | `width` / `height` | `400` / `620` | Size of the popover (and of the inline panel). |
226
+ | `launcherLabel` | — | Text beside the bubble glyph. Icon-only without it. |
227
+ | `launcherIcon` | speech bubble | Replaces the default glyph. |
228
+ | `defaultOpen` | `false` | Open on first render. |
229
+ | `open` / `onOpenChange` | — | Control the open state yourself (e.g. from your own "Help" button). |
230
+
231
+ A `'fullscreen'` panel covers the page and closes from an X in the chat header;
232
+ a popover closes from the bubble, which becomes an X. Escape closes either, and
233
+ below 480px wide a popover goes full-screen on its own. The conversation is kept
234
+ across close/open — a run that is still streaming keeps streaming.
235
+
236
+ To give your own layout the same behaviour, wrap it in `<ChatLauncher>`:
237
+
238
+ ```tsx
239
+ <ChatLauncher position="bottom-left" panel="fullscreen" label="Support">
240
+ <ChatWindow chat={chat} />
241
+ </ChatLauncher>
242
+ ```
243
+
244
+ ### Errors
245
+
246
+ A failed run leaves its message on `chat.error`, but nothing is rendered by
247
+ default — raw backend errors are long, and they crowd a narrow window. Opt in
248
+ with `showErrors` on `<AgnoChat>` or `<ChatWindow>`:
249
+
250
+ ```tsx
251
+ <AgnoChat baseUrl="http://localhost:7777" showErrors />
252
+ ```
253
+
254
+ Or render it wherever suits your layout — a toast, say:
255
+
256
+ ```tsx
257
+ {chat.error && <Toast>{chat.error}</Toast>}
258
+ ```
259
+
260
+ ### Quick prompts
261
+
262
+ `quickPrompts` offers suggested prompts while the transcript is empty. Clicking
263
+ one sends it. A chip is either the prompt itself, or a short label that sends
264
+ something longer:
265
+
266
+ ```tsx
267
+ <AgnoChat
268
+ baseUrl="http://localhost:7777"
269
+ quickPrompts={[
270
+ 'What can you do?',
271
+ { label: 'Pricing', prompt: 'How does pricing work?' },
272
+ { label: 'Talk to a human', prompt: 'I would like to talk to a human.' },
273
+ ]}
274
+ />
275
+ ```
276
+
277
+ `<ChatWindow>` takes the same prop, plus `onQuickPrompt` to do something other
278
+ than send the text. Render `<QuickPrompts>` yourself to place the chips
279
+ elsewhere.
280
+
281
+ ### Building on the composer
282
+
283
+ `<ChatInput>` is the AgentOS dock: an auto-growing textarea, attach, send and
284
+ stop. `compact` trades the dock for one row — the field with the buttons beside
285
+ it — for a launcher, a sidebar, anywhere narrow; `compactInput` on `<AgnoChat>`
286
+ and `<ChatWindow>` switches their built-in composer to it:
287
+
288
+ ```tsx
289
+ <AgnoChat baseUrl="http://localhost:7777" mode="launcher" compactInput />
290
+ <ChatInput compact placeholder="Send a message" />
291
+ ```
115
292
 
116
- The built-in renderer covers code blocks, inline code, bold/italic, links,
117
- headings and lists. For full GFM (tables, etc.), pass `renderMarkdown`:
293
+ When a product needs more from the composer — an @mention menu, context
294
+ chips, a highlighted draft it opens up rather than being replaced:
295
+
296
+ ```tsx
297
+ <ChatInput
298
+ value={draft} // own the draft
299
+ onValueChange={setDraft}
300
+ textareaRef={inputRef}
301
+ textareaProps={{ // reach the textarea itself
302
+ onKeyDown: (e) => { if (menu.handles(e)) e.preventDefault() }, // claims the key
303
+ role: 'combobox',
304
+ 'aria-expanded': menuOpen,
305
+ }}
306
+ renderTextarea={(props) => <HighlightedTextarea {...props} />} // or swap it
307
+ above={menuOpen && <MentionMenu />} // inside the dock, over the field
308
+ below={contextError && <p>{contextError}</p>}
309
+ allowFiles
310
+ accept=".md,.py,image/*" // dropped files obey this too
311
+ maxFiles={8}
312
+ maxFileSize={20 * 1024 * 1024}
313
+ onSend={async (text, files) => {
314
+ const ok = await prepare(text)
315
+ if (!ok) return false // keep the draft
316
+ chat.sendMessage(text, files ? { files } : undefined)
317
+ }}
318
+ />
319
+ ```
320
+
321
+ A key handler in `textareaProps` runs before Enter-to-send; `preventDefault()`
322
+ there claims the key. Files can be dropped anywhere on the dock; the first
323
+ reason one was refused shows under the attachment chips (`admitFiles` is the
324
+ same check, exported).
325
+
326
+ ### Links
327
+
328
+ Every link the components render — in an answer, on a `[1]` chip, on a source
329
+ card — is a plain `<a target="_blank">`. Inside an app with a router, hand the
330
+ provider a `linkComponent` and in-app URLs stay client-side:
331
+
332
+ ```tsx
333
+ <ChatProvider linkComponent={DocsLink}>…</ChatProvider>
334
+ <AgnoChat linkComponent={DocsLink} />
335
+ ```
336
+
337
+ It receives `href`, `children` and the anchor attributes the component would
338
+ have set, and decides how to navigate — nothing sets `target` for it, so a
339
+ link opens in the same page unless the component says otherwise. The usual
340
+ shape routes your own origin through the router and pops everything else out:
341
+
342
+ ```tsx
343
+ import { Link } from 'react-router-dom'
344
+ import type { LinkComponent } from '@agno-hq/chat-react/chat'
345
+
346
+ const DocsLink: LinkComponent = ({ href, children, ...rest }) => {
347
+ const url = new URL(href, window.location.href)
348
+ return url.origin === window.location.origin ? (
349
+ <Link to={url.pathname + url.search + url.hash} {...rest}>
350
+ {children}
351
+ </Link>
352
+ ) : (
353
+ <a href={href} target="_blank" rel="noreferrer noopener" {...rest}>
354
+ {children}
355
+ </a>
356
+ )
357
+ }
358
+ ```
359
+
360
+ Sources with no URL — retrieved knowledge-base chunks — are never links; their
361
+ `[1]` chip points at the card below the answer.
362
+
363
+ ### Run events
364
+
365
+ Every run keeps the events that produced it on its own message
366
+ (`message.events`) — live, and for runs restored from session history when the
367
+ agent stores them. `<BehindTheScenes>` builds its steps from those events the
368
+ way the AgentOS chat does: content deltas, model-request and reasoning-delta
369
+ events are dropped, started/completed pairs collapse into one step, and run
370
+ lifecycle events read as "Run Started" / "Run Completed" with the reported
371
+ duration. A message with no events falls back to what it does carry (tools,
372
+ reasoning, member turns, workflow steps).
373
+
374
+ The raw feed is not part of that panel. `chat.events` holds the latest run's
375
+ events, and every message keeps its own in `message.events` — render either
376
+ with `<EventLog>` wherever you want it:
377
+
378
+ ```tsx
379
+ <EventLog events={chat.events} maxHeight={240} />
380
+ ```
381
+
382
+ ### Citations, sources and link previews
383
+
384
+ Whatever a run cites — knowledge-base chunks in `references`, browsed links in
385
+ `citations.urls` — is flattened into one numbered list and rendered as cards
386
+ under the answer: the site's favicon and the title, linking to the source. A
387
+ `[1]` in the answer text becomes a chip pointing at card one, and hovering
388
+ either the chip or a link opens a preview with the URL and the cited passage:
389
+
390
+ ```tsx
391
+ <Message message={message} /> // markers, cards and previews, no props
392
+ <Message message={message} hideSources /> // just the answer
393
+ ```
394
+
395
+ The hostname and favicon come from the URL itself, and the title and passage
396
+ from the citation payload, so previews need no network of their own. To fill in
397
+ OpenGraph metadata, hand the provider a `resolveLinkPreview` — the browser can't
398
+ read another origin's meta tags, so back it with an endpoint of your own. It is
399
+ called once per URL, on first hover:
400
+
401
+ ```tsx
402
+ <ChatProvider
403
+ baseUrl="http://localhost:7777"
404
+ resolveLinkPreview={async (url) => {
405
+ const res = await fetch(`/api/og?url=${encodeURIComponent(url)}`)
406
+ return res.ok ? res.json() : null // { title, description, image, siteName, favicon }
407
+ }}
408
+ >
409
+ <ChatWindow />
410
+ </ChatProvider>
411
+ ```
412
+
413
+ Plenty of runs carry no `references` or `citations` at all — the model cites in
414
+ the answer itself, as numbered links, and lists the titles at the end:
415
+
416
+ ```text
417
+ Call `Agent.run()` in production. [1](https://docs.agno.com/agents/building-agents)
418
+
419
+ Sources: [Building Agents](https://docs.agno.com/agents/building-agents)
420
+ ```
421
+
422
+ That reads the same way: the numbered link becomes the chip, the labelled link
423
+ names the card, and the numbering stays exactly as the model wrote it. Links in
424
+ prose are left alone — only numbered ones count as citations.
425
+
426
+ Rendering sources yourself? `collectSources(references, citations)` and
427
+ `sourcesFromMarkdown(content)` are the same numbering the components use, and
428
+ `<SourceCard>`, `<LinkPreviewCard>` and `<HoverPreview>` are exported to build
429
+ with.
430
+
431
+ ### Follow-ups
432
+
433
+ An agent built with `followups=True` suggests what to ask next once it has
434
+ answered. They sit above the composer as "Related questions", one line each —
435
+ the full text is on hover — and clicking one sends it:
436
+
437
+ ```tsx
438
+ <AgnoChat baseUrl="http://localhost:7777" /> // on by default
439
+ <AgnoChat baseUrl="http://localhost:7777" hideFollowups /> // off
440
+ <AgnoChat baseUrl="http://localhost:7777" followupsTitle="Ask next" /> // your own heading
441
+ ```
442
+
443
+ Only the latest answer offers them; earlier ones have been answered by the
444
+ conversation moving on. They arrive as a `FollowupsCompleted` event and are
445
+ stored on the run, so a reloaded session still shows them. To put them under
446
+ the answer instead, pass `<Message onFollowup={chat.sendMessage}>`; to place
447
+ them anywhere else, render `<Followups>` yourself from `message.followups` —
448
+ `lines` lets them wrap instead of clamping.
449
+
450
+ ### Markdown
451
+
452
+ Answers are rendered by [Streamdown](https://streamdown.ai), which is built for
453
+ text that arrives a token at a time: mid-stream it repairs the unterminated
454
+ bold, the half-written link and the unclosed fence instead of showing you the
455
+ raw characters. GFM comes with it — tables, task lists, strikethrough — along
456
+ with a sanitising pass over the HTML.
457
+
458
+ Streamdown styles itself with Tailwind utility classes. Every element is mapped
459
+ back onto this package's own `agno-md-*` classes, so **Tailwind is not
460
+ required** and the shipped stylesheet and tokens still govern the look.
461
+
462
+ Fenced code blocks get a header naming the language, a line-number gutter and
463
+ syntax highlighting by [Shiki](https://shiki.style). The highlighter loads on
464
+ the first fenced block, not before, and grammars load one at a time as
465
+ languages appear; a language Shiki doesn't know renders plain. Colours follow
466
+ the theme class like everything else. `--agno-code-max-height` (default `32rem`)
467
+ caps a block before it scrolls.
468
+
469
+ The header also holds a copy button. Turn it off per-renderer or for a whole
470
+ tree:
471
+
472
+ ```tsx
473
+ <Markdown content={answer} codeCopy={false} />
474
+ <ChatProvider codeCopy={false}>…</ChatProvider>
475
+ <AgnoChat codeCopy={false} />
476
+ ```
477
+
478
+ Prefer a different renderer? `renderMarkdown` replaces it entirely:
118
479
 
119
480
  ```tsx
120
481
  import ReactMarkdown from 'react-markdown'
@@ -126,6 +487,10 @@ import remarkGfm from 'remark-gfm'
126
487
  />
127
488
  ```
128
489
 
490
+ Note that `streamdown` is ESM-only, as is the remark/rehype ecosystem under it.
491
+ Bundlers resolve that fine; a bare `require()` of the CJS entry needs Node
492
+ 22.12+.
493
+
129
494
  ---
130
495
 
131
496
  ## Human-in-the-loop
@@ -161,12 +526,20 @@ await chat.loadSession(sessionId) // load a transcript into the chat
161
526
  await chat.deleteSession(sessionId)
162
527
  ```
163
528
 
529
+ The list updates optimistically, as the AgentOS chat does: the moment a run
530
+ reports its session id, that session moves to the top of `chat.sessions` —
531
+ created on the spot, named after the message that started it, if the backend
532
+ just made it — rather than only appearing after the next `refreshSessions()`.
533
+ A later refresh keeps the in-flight session even if the backend has not
534
+ persisted it yet.
535
+
164
536
  Render them with `<SessionList>`:
165
537
 
166
538
  ```tsx
167
539
  <SessionList
168
540
  sessions={chat.sessions}
169
541
  activeSessionId={chat.sessionId}
542
+ streamingSessionIds={chat.streamingSessionIds}
170
543
  loading={chat.sessionsLoading}
171
544
  onSelect={chat.loadSession}
172
545
  onDelete={chat.deleteSession}
@@ -174,10 +547,22 @@ Render them with `<SessionList>`:
174
547
  />
175
548
  ```
176
549
 
550
+ `streamingSessionIds` marks the sessions with a run in flight — each shows a
551
+ spinner in place of the delete button.
552
+
553
+ ### Switching session does not stop the run
554
+
555
+ Selecting another session (or starting a new chat) while a run is streaming
556
+ *parks* that run instead of cancelling it, as the AgentOS chat does: it keeps
557
+ streaming in the background, stays listed in `chat.streamingSessionIds`, and
558
+ `loadSession` restores it — mid-stream or finished — when you switch back,
559
+ rather than refetching a transcript that is still being written. Only
560
+ `chat.cancel()` stops a run.
561
+
177
562
  The all-in-one widget shows this sidebar with `showSessions`:
178
563
 
179
564
  ```tsx
180
- <AgnoChat baseUrl="http://localhost:7777" showSessions showEventLog />
565
+ <AgnoChat baseUrl="http://localhost:7777" showSessions />
181
566
  ```
182
567
 
183
568
  ---
@@ -202,8 +587,9 @@ normalised events) if you want to bypass the hook entirely.
202
587
 
203
588
  ## Running the example
204
589
 
205
- The `example/` folder is a Vite app demonstrating both the widget and the hook
206
- (with a live status panel + event log). It imports the library from source.
590
+ The `example/` folder is a Vite app demonstrating the widget, the floating
591
+ launcher, every component, and the hook (with a live status panel + event log).
592
+ It imports the library from source.
207
593
 
208
594
  ```bash
209
595
  cd example