@cubos/agent-sdk-react-dom
Ready-made React components for a Cubos Agent chat. Everything here renders DOM —
that is the whole difference from @cubos/agent-sdk-react, which
stays platform-neutral so its hooks also run in React Native.
bun add @cubos/agent-sdk-react-dom # or npm / pnpm / yarn
Its dependencies are the markdown chain and lucide-react for the handful of
icons a player needs. React is a peer.
Agent markdown
import { AgentMarkdown } from "@cubos/agent-sdk-react-dom";
<AgentMarkdown>{message.content}</AgentMarkdown>;
That is the whole setup. GFM tables, fenced code with syntax highlighting, TeX, and a look that already works — no stylesheet to import.
The CSS takes care of itself
The components import their own stylesheet. Your bundler resolves that the way
it resolves any other import — Vite, Next, Rspack, webpack, Bun — and there is
nothing for you to remember, no order to get right, no styles.css to hunt for
in a README.
Two properties make that safe to do in someone else's app:
- Nothing escapes. Every selector is scoped to a class this package puts on
its own wrapper:
.cubos-agent-sdk-react-domfor what every component here shares,.cubos-agent-sdk-react-dom--markdownfor this one's rules. The sheet cannot touch anything it didn't draw. - Nothing wins an argument. The whole sheet sits in the
cubos-agent-sdk-react-domcascade layer, and every selector is wrapped in:where(), which contributes no specificity. A layer loses to unlayered CSS outright, and a barep {}beats a zero-specificity rule — so these are defaults, and yours are decisions.
If your app puts its own CSS in layers (Tailwind v4 does), name ours once so the order is explicit rather than accidental:
/* Above your `@import`, since that is where a layer's position gets fixed. */
@layer theme, base, cubos-agent-sdk-react-dom, components, utilities;
@import "tailwindcss";
After your reset, before your own classes. A reset clears borders and
margins on *; below it, these defaults would be erased along with the
browser's. Above your components and utilities, and your app would have to fight
this package to restyle anything.
Theming is --cubos-agent-sdk-react-dom-*, on the package class — one block, and
every component in here follows it:
.cubos-agent-sdk-react-dom {
--cubos-agent-sdk-react-dom-border: var(--my-border);
--cubos-agent-sdk-react-dom-surface: var(--my-code-bg);
--cubos-agent-sdk-react-dom-keyword: var(--my-pink);
}
Most of it you never touch: borders, muted text and code surfaces are mixed from
currentColor, so the same sheet reads correctly on a white page, a dark one, or
inside a coloured bubble. Only the six syntax colours are absolute, and they
switch on prefers-color-scheme — if your app toggles dark mode itself, override
those under whatever selector you use.
The same tokens are a prop, for an app that themes in JavaScript or wants one instance to differ from the rest:
<VoiceMessage colors={{ surface: "#0b57d0", border: "#8ab4f8" }} … />
They land as inline custom properties, which is the app speaking directly — it
outranks every stylesheet, this package's included, without an !important
anywhere.
Making it yours
// Your design system, element by element. Anything you style here outranks the
// packaged default for that element; anything you leave alone keeps it.
<AgentMarkdown components={{ p: (props) => <p className="my-2" {...props} /> }}>
{text}
</AgentMarkdown>;
components is react-markdown's map, merged over the defaults — so overriding
a leaves the table scroller in place. gfm, math and highlight each turn
their plugin off. That is the whole API: there is no unstyled flag, because
with a scoped layer at zero specificity there is nothing to opt out of.
Two things worth knowing
Raw HTML is never rendered, and there is no option to allow it. Message
content is model output, which is untrusted input however friendly the agent.
rehype-raw is deliberately absent: markdown in, elements out.
KaTeX is yours: it renders with your katex, and its stylesheet is your
import. katex is a peer dependency, so the copy that writes the markup is the
copy whose CSS styles it — KaTeX's stylesheet is written for its own release's
markup, and two copies drifting apart mis-style formulas with nothing failing.
The stylesheet is not bundled either: it ships fonts, and re-exporting a copy of
those from here would double them in your bundle. Add it once in your app:
bun add katex
import "katex/dist/katex.min.css";
Without the stylesheet formulas still render, just unstyled. math={false} if
the agent never writes any.
Voice messages
import { VoiceMessage } from "@cubos/agent-sdk-react-dom";
<VoiceMessage
loadBytes={(signal) => client.fetchAttachment(conversationId, message.id, attachment.id, signal)}
bytes={attachment.bytes}
transcript={message.content}
/>;
It fills the width of whatever holds it — how wide a voice message is, is the bubble's decision, not the player's. All it insists on is a floor, so a container with no width of its own doesn't collapse around a waveform that is absolutely positioned.
Play/pause, a seek bar, the elapsed time, and the transcription folded away
under a disclosure — collapsed by default, because the player is the message and
the words are there when you want them. defaultTranscriptOpen starts it open.
Three things it does that are easy to miss:
- The waveform is the clip's own. It is read off the decoded samples — peak amplitude per bucket, normalised — so a pause in the recording is a gap on screen. Until the bytes are here there is nothing to read, and the track is a plain progress bar rather than an invented pattern. A codec the browser can't decode keeps that bar; it never breaks playback.
- Nothing is fetched until you press play. A transcript often answers the question, and a conversation full of voice notes should not pull every clip down to render one screen.
- The seek control is a real
<input type="range">, for the keyboard and the ARIA — painted by us, because the native thumb needs a vendor pseudo-element and hiding it withopacityloses to Tailwind's preflight, which setsopacity: 1on everyinputfrom a layer above this package's. - It measures a
MediaRecorderclip. Those carry no duration in their header, so browsers reportInfinityuntil the file has been seeked through; the component forces that measurement and rewinds, which is why the seek bar has a range at all.
loadBytes returns a Blob because the bytes usually sit behind a bearer
token, which an <audio src> cannot carry. Every string is in labels:
<VoiceMessage
loadBytes={…}
labels={{ play: "Tocar", pause: "Pausar", transcript: "Transcrição" }}
/>
Recording a voice message
useRecorder is the other half of VoiceMessage: capture, as a Blob ready
for sendAudio.
const rec = useRecorder();
{
rec.state !== "unsupported" && (
<button onClick={() => (rec.state === "recording" ? finish() : rec.start())}>
{rec.state === "recording" ? `Gravando ${rec.seconds}s` : "Gravar"}
</button>
);
}
async function finish() {
const clip = await rec.stop(); // null when nothing was captured
if (clip) await sendAudio(clip);
}
Two things it owns, both silent when wrong:
- The container is probed, not chosen. Chrome and Firefox record WebM; Safari only ever produces MP4/AAC and fails silently when asked for WebM — a clip that is simply never assembled.
- The stream is released on every exit path — stop, cancel, unmount, a
failed permission. A live
MediaStreamkeeps the browser's recording indicator lit long after the UI has moved on, with nothing on screen to explain it.
state is unsupported where there is no MediaRecorder or no secure context
(getUserMedia exists only in one), and it settles after mount rather than
during render, so it is safe to render on a server. cancel() drops the take
without assembling it; error carries a denied microphone, which is the failure
worth putting in front of someone.
Keeping the transcript anchored
useTranscriptAnchor is the scroll behaviour of a bottom-anchored transcript
with none of its looks: it renders no element, sets no class, and hands you two
refs.
const { scrollRef, contentRef, onScroll, isAtBottom, scrollToEnd } = useTranscriptAnchor({
tailKey: messages.at(-1)?.id,
hasOlder,
loadOlder,
});
<div ref={scrollRef} onScroll={onScroll} className="overflow-auto">
<div ref={contentRef}>{items}</div>
</div>;
{
!isAtBottom && <button onClick={scrollToEnd}>Ver o mais recente</button>;
}
tailKey is anything that changes when the tail grows — the last event's seq,
the message count, whether a typing indicator is up. It is only a trigger; the
value is never read. loadOlder resolves with how many items it prepended,
which is what lets a page that came back empty release the anchor instead of
freezing the viewport.
Four behaviours, each of which was a bug in this repository before it was a rule:
- Follow only if already following. The intent is recorded when the reader scrolls, never measured after an update — by then the content has grown, so someone sitting exactly at the end is suddenly "far" from it by the height of whatever just arrived.
- Follow when the content grows, not when React re-renders. A transcript
grows for reasons no state changed: an image decodes, a font swaps, a
disclosure opens, a card lays out a frame late. That is what
contentRefwatches, and skipping it leaves the last few hundred pixels — the ones the answer is written in — below the fold. - Hold the reader's place when paging backwards, before paint, against a height measured before the commit. The correction survives the renders that happen while the page is in flight; consuming it on the first of them is what drops the reader at the top of a page they never asked to jump to.
- Keep pulling while the content is shorter than the viewport, or a short first page leaves a box that cannot scroll, and no scroll event ever fires to ask for the rest.
What belongs in this package
The bar is deliberately high, because a components package that answers every request becomes a chat UI nobody can restyle:
- It solves a problem of the protocol — markdown the model wrote, bytes behind a bearer token, a clip that needs a player — or it is a mechanic that is invisible when right and wrong in the same way everywhere, like a transcript that has to follow the answer being written into it. Never a question of product taste.
- There is one obviously right behaviour, or a security invariant worth keeping in one place.
- Styling is delegated, not baked in.
- It was already written twice.
Bubbles, composers and conversation lists fail (1): they are where products
differ most, and the dashboard's packages/web/components/chats/ in the
repository is the worked example to copy instead. Recording a voice note is the
next candidate — the dashboard has it, and the question is only whether a
recorder belongs next to a player.