@cubos/agent-sdk-react-dom 0.0.1136563

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 ADDED
@@ -0,0 +1,265 @@
1
+ # @cubos/agent-sdk-react-dom
2
+
3
+ Ready-made React components for a Cubos Agent chat. Everything here renders DOM —
4
+ that is the whole difference from [`@cubos/agent-sdk-react`](../sdk-react), which
5
+ stays platform-neutral so its hooks also run in React Native.
6
+
7
+ ```sh
8
+ bun add @cubos/agent-sdk-react-dom # or npm / pnpm / yarn
9
+ ```
10
+
11
+ Its dependencies are the markdown chain and `lucide-react` for the handful of
12
+ icons a player needs. React is a peer.
13
+
14
+ ## Agent markdown
15
+
16
+ ```tsx
17
+ import { AgentMarkdown } from "@cubos/agent-sdk-react-dom";
18
+
19
+ <AgentMarkdown>{message.content}</AgentMarkdown>
20
+ ```
21
+
22
+ That is the whole setup. GFM tables, fenced code with syntax highlighting, TeX,
23
+ and a look that already works — **no stylesheet to import**.
24
+
25
+ ### The CSS takes care of itself
26
+
27
+ The components import their own stylesheet. Your bundler resolves that the way
28
+ it resolves any other import — Vite, Next, Rspack, webpack, Bun — and there is
29
+ nothing for you to remember, no order to get right, no `styles.css` to hunt for
30
+ in a README.
31
+
32
+ Two properties make that safe to do in someone else's app:
33
+
34
+ - **Nothing escapes.** Every selector is scoped to a class this package puts on
35
+ its own wrapper: `.cubos-agent-sdk-react-dom` for what every component here
36
+ shares, `.cubos-agent-sdk-react-dom--markdown` for this one's rules. The sheet
37
+ cannot touch anything it didn't draw.
38
+ - **Nothing wins an argument.** The whole sheet sits in the `cubos-agent-sdk-react-dom`
39
+ cascade layer, and every selector is wrapped in `:where()`, which contributes
40
+ no specificity. A layer loses to unlayered CSS outright, and a bare `p {}`
41
+ beats a zero-specificity rule — so these are defaults, and yours are
42
+ decisions.
43
+
44
+ **If your app puts its own CSS in layers** (Tailwind v4 does), name ours once so
45
+ the order is explicit rather than accidental:
46
+
47
+ ```css
48
+ /* Above your `@import`, since that is where a layer's position gets fixed. */
49
+ @layer theme, base, cubos-agent-sdk-react-dom, components, utilities;
50
+
51
+ @import "tailwindcss";
52
+ ```
53
+
54
+ **After your reset, before your own classes.** A reset clears borders and
55
+ margins on `*`; below it, these defaults would be erased along with the
56
+ browser's. Above your components and utilities, and your app would have to fight
57
+ this package to restyle anything.
58
+
59
+ Theming is `--cubos-agent-sdk-react-dom-*`, on the package class — one block, and
60
+ every component in here follows it:
61
+
62
+ ```css
63
+ .cubos-agent-sdk-react-dom {
64
+ --cubos-agent-sdk-react-dom-border: var(--my-border);
65
+ --cubos-agent-sdk-react-dom-surface: var(--my-code-bg);
66
+ --cubos-agent-sdk-react-dom-keyword: var(--my-pink);
67
+ }
68
+ ```
69
+
70
+ Most of it you never touch: borders, muted text and code surfaces are mixed from
71
+ `currentColor`, so the same sheet reads correctly on a white page, a dark one, or
72
+ inside a coloured bubble. Only the six syntax colours are absolute, and they
73
+ switch on `prefers-color-scheme` — if your app toggles dark mode itself, override
74
+ those under whatever selector you use.
75
+
76
+ The same tokens are a prop, for an app that themes in JavaScript or wants one
77
+ instance to differ from the rest:
78
+
79
+ ```tsx
80
+ <VoiceMessage colors={{ surface: "#0b57d0", border: "#8ab4f8" }} … />
81
+ ```
82
+
83
+ They land as inline custom properties, which is the app speaking directly — it
84
+ outranks every stylesheet, this package's included, without an `!important`
85
+ anywhere.
86
+
87
+ ### Making it yours
88
+
89
+ ```tsx
90
+ // Your design system, element by element. Anything you style here outranks the
91
+ // packaged default for that element; anything you leave alone keeps it.
92
+ <AgentMarkdown components={{ p: (props) => <p className="my-2" {...props} /> }}>
93
+ {text}
94
+ </AgentMarkdown>
95
+ ```
96
+
97
+ `components` is `react-markdown`'s map, merged over the defaults — so overriding
98
+ `a` leaves the table scroller in place. `gfm`, `math` and `highlight` each turn
99
+ their plugin off. That is the whole API: there is no `unstyled` flag, because
100
+ with a scoped layer at zero specificity there is nothing to opt out of.
101
+
102
+ ### Two things worth knowing
103
+
104
+ **Raw HTML is never rendered, and there is no option to allow it.** Message
105
+ content is model output, which is untrusted input however friendly the agent.
106
+ `rehype-raw` is deliberately absent: markdown in, elements out.
107
+
108
+ **KaTeX's stylesheet is not bundled.** It ships fonts, and re-exporting a copy of
109
+ those from here would double them in your bundle. If your agent writes TeX, add
110
+ it once in your app:
111
+
112
+ ```ts
113
+ import "katex/dist/katex.min.css";
114
+ ```
115
+
116
+ Without it formulas still render, just unstyled. `math={false}` if the agent
117
+ never writes any.
118
+
119
+ ## Voice messages
120
+
121
+ ```tsx
122
+ import { VoiceMessage } from "@cubos/agent-sdk-react-dom";
123
+
124
+ <VoiceMessage
125
+ loadBytes={(signal) => client.fetchAttachment(conversationId, message.id, attachment.id, signal)}
126
+ bytes={attachment.bytes}
127
+ transcript={message.content}
128
+ />
129
+ ```
130
+
131
+ It **fills the width of whatever holds it** — how wide a voice message is, is
132
+ the bubble's decision, not the player's. All it insists on is a floor, so a
133
+ container with no width of its own doesn't collapse around a waveform that is
134
+ absolutely positioned.
135
+
136
+ Play/pause, a seek bar, the elapsed time, and the transcription folded away
137
+ under a disclosure — collapsed by default, because the player is the message and
138
+ the words are there when you want them. `defaultTranscriptOpen` starts it open.
139
+
140
+ Three things it does that are easy to miss:
141
+
142
+ - **The waveform is the clip's own.** It is read off the decoded samples — peak
143
+ amplitude per bucket, normalised — so a pause in the recording is a gap on
144
+ screen. Until the bytes are here there is nothing to read, and the track is a
145
+ plain progress bar rather than an invented pattern. A codec the browser can't
146
+ decode keeps that bar; it never breaks playback.
147
+ - **Nothing is fetched until you press play.** A transcript often answers the
148
+ question, and a conversation full of voice notes should not pull every clip
149
+ down to render one screen.
150
+ - **The seek control is a real `<input type="range">`,** for the keyboard and
151
+ the ARIA — painted by us, because the native thumb needs a vendor
152
+ pseudo-element and hiding it with `opacity` loses to Tailwind's preflight,
153
+ which sets `opacity: 1` on every `input` from a layer above this package's.
154
+ - **It measures a `MediaRecorder` clip.** Those carry no duration in their
155
+ header, so browsers report `Infinity` until the file has been seeked through;
156
+ the component forces that measurement and rewinds, which is why the seek bar
157
+ has a range at all.
158
+
159
+ `loadBytes` returns a `Blob` because the bytes usually sit behind a bearer
160
+ token, which an `<audio src>` cannot carry. Every string is in `labels`:
161
+
162
+ ```tsx
163
+ <VoiceMessage
164
+ loadBytes={…}
165
+ labels={{ play: "Tocar", pause: "Pausar", transcript: "Transcrição" }}
166
+ />
167
+ ```
168
+
169
+ ## Recording a voice message
170
+
171
+ `useRecorder` is the other half of `VoiceMessage`: capture, as a `Blob` ready
172
+ for `sendAudio`.
173
+
174
+ ```tsx
175
+ const rec = useRecorder();
176
+
177
+ {rec.state !== "unsupported" && (
178
+ <button onClick={() => (rec.state === "recording" ? finish() : rec.start())}>
179
+ {rec.state === "recording" ? `Gravando ${rec.seconds}s` : "Gravar"}
180
+ </button>
181
+ )}
182
+
183
+ async function finish() {
184
+ const clip = await rec.stop(); // null when nothing was captured
185
+ if (clip) await sendAudio(clip);
186
+ }
187
+ ```
188
+
189
+ Two things it owns, both silent when wrong:
190
+
191
+ - **The container is probed, not chosen.** Chrome and Firefox record WebM;
192
+ Safari only ever produces MP4/AAC and *fails silently* when asked for WebM —
193
+ a clip that is simply never assembled.
194
+ - **The stream is released on every exit path** — stop, cancel, unmount, a
195
+ failed permission. A live `MediaStream` keeps the browser's recording
196
+ indicator lit long after the UI has moved on, with nothing on screen to
197
+ explain it.
198
+
199
+ `state` is `unsupported` where there is no `MediaRecorder` or no secure context
200
+ (`getUserMedia` exists only in one), and it settles after mount rather than
201
+ during render, so it is safe to render on a server. `cancel()` drops the take
202
+ without assembling it; `error` carries a denied microphone, which is the failure
203
+ worth putting in front of someone.
204
+
205
+ ## Keeping the transcript anchored
206
+
207
+ `useTranscriptAnchor` is the scroll behaviour of a bottom-anchored transcript
208
+ with none of its looks: it renders no element, sets no class, and hands you two
209
+ refs.
210
+
211
+ ```tsx
212
+ const { scrollRef, contentRef, onScroll, isAtBottom, scrollToEnd } =
213
+ useTranscriptAnchor({ tailKey: messages.at(-1)?.id, hasOlder, loadOlder });
214
+
215
+ <div ref={scrollRef} onScroll={onScroll} className="overflow-auto">
216
+ <div ref={contentRef}>{items}</div>
217
+ </div>;
218
+ {!isAtBottom && <button onClick={scrollToEnd}>Ver o mais recente</button>}
219
+ ```
220
+
221
+ `tailKey` is anything that changes when the tail grows — the last event's `seq`,
222
+ the message count, whether a typing indicator is up. It is only a trigger; the
223
+ value is never read. `loadOlder` resolves with **how many items it prepended**,
224
+ which is what lets a page that came back empty release the anchor instead of
225
+ freezing the viewport.
226
+
227
+ Four behaviours, each of which was a bug in this repository before it was a
228
+ rule:
229
+
230
+ 1. **Follow only if already following.** The intent is recorded when the reader
231
+ scrolls, never measured after an update — by then the content has grown, so
232
+ someone sitting exactly at the end is suddenly "far" from it by the height of
233
+ whatever just arrived.
234
+ 2. **Follow when the content grows, not when React re-renders.** A transcript
235
+ grows for reasons no state changed: an image decodes, a font swaps, a
236
+ disclosure opens, a card lays out a frame late. That is what `contentRef`
237
+ watches, and skipping it leaves the last few hundred pixels — the ones the
238
+ answer is written in — below the fold.
239
+ 3. **Hold the reader's place when paging backwards**, before paint, against a
240
+ height measured before the commit. The correction survives the renders that
241
+ happen *while* the page is in flight; consuming it on the first of them is
242
+ what drops the reader at the top of a page they never asked to jump to.
243
+ 4. **Keep pulling while the content is shorter than the viewport**, or a short
244
+ first page leaves a box that cannot scroll, and no scroll event ever fires to
245
+ ask for the rest.
246
+
247
+ ## What belongs in this package
248
+
249
+ The bar is deliberately high, because a components package that answers every
250
+ request becomes a chat UI nobody can restyle:
251
+
252
+ 1. It solves a problem of the **protocol** — markdown the model wrote, bytes
253
+ behind a bearer token, a clip that needs a player — or it is a **mechanic
254
+ that is invisible when right and wrong in the same way everywhere**, like a
255
+ transcript that has to follow the answer being written into it. Never a
256
+ question of product taste.
257
+ 2. There is one obviously right behaviour, or a security invariant worth keeping
258
+ in one place.
259
+ 3. Styling is delegated, not baked in.
260
+ 4. It was already written twice.
261
+
262
+ Bubbles, composers and conversation lists fail (1): they are where products
263
+ differ most, and `packages/demo` in the repository is the worked example to copy
264
+ instead. Recording a voice note is the next candidate — the demo has it, and the
265
+ question is only whether a recorder belongs next to a player.
@@ -0,0 +1,53 @@
1
+ import { type Components } from "react-markdown";
2
+ import { type AgentColors, type SyntaxColors } from "./colors.js";
3
+ import "./styles.css";
4
+ export interface AgentMarkdownProps {
5
+ /** The markdown, as the agent wrote it — a `Message.content`, or a
6
+ * `markdown` block's `text`. */
7
+ children: string;
8
+ /** GitHub-flavoured markdown: tables, strikethrough, task lists, autolinks.
9
+ * Defaults to on. */
10
+ gfm?: boolean;
11
+ /** TeX through KaTeX (`$x$` inline, `$$…$$` in display). Defaults to on.
12
+ * **KaTeX's own stylesheet is not bundled** — add `import
13
+ * "katex/dist/katex.min.css"` to your app, or formulas render unstyled.
14
+ *
15
+ * Amounts are safe with this on: a `$` that reads as currency is escaped
16
+ * before the parser sees it, so "de R$ 524 a R$ 632" stays a sentence instead
17
+ * of becoming one formula. See `currency.ts` for what counts. */
18
+ math?: boolean;
19
+ /** Syntax highlighting for fenced code. Defaults to on. */
20
+ highlight?: boolean;
21
+ /**
22
+ * Element overrides, as `react-markdown` takes them. Merged over the
23
+ * defaults, so overriding `a` leaves the table scroller in place.
24
+ *
25
+ * This is the hook for an app with its own design system: pass
26
+ * `{ p: (props) => <p className="my-2" {...props} /> }` and style everything
27
+ * with your own classes.
28
+ */
29
+ components?: Components;
30
+ /** Overrides the palette for this block, as inline custom properties — the
31
+ * same tokens `styles.css` declares, without writing CSS. */
32
+ colors?: AgentColors & SyntaxColors;
33
+ /** Put on the wrapper, alongside this package's own classes. */
34
+ className?: string;
35
+ }
36
+ /**
37
+ * Markdown the way an agent writes it: GFM, fenced code with highlighting, and
38
+ * TeX — styled out of the box, with no stylesheet to import, and restyleable
39
+ * down to the element through `components`.
40
+ *
41
+ * ```tsx
42
+ * <AgentMarkdown>{message.content}</AgentMarkdown>
43
+ * ```
44
+ *
45
+ * **Raw HTML is never rendered.** `rehype-raw` is deliberately absent and there
46
+ * is no option to add it: this text is model output, which is untrusted input
47
+ * however friendly the agent. Markdown in, elements out.
48
+ *
49
+ * Memoised on its input — a live conversation re-renders the whole transcript
50
+ * whenever a message arrives, and re-parsing every bubble each time is work
51
+ * nobody sees.
52
+ */
53
+ export declare const AgentMarkdown: import("react").MemoExoticComponent<({ children, gfm, math, highlight, components, colors, className, }: AgentMarkdownProps) => import("react").JSX.Element>;
@@ -0,0 +1,24 @@
1
+ import type { CSSProperties } from "react";
2
+ /** The colours every component here reads. Each is a `--cubos-agent-sdk-react-dom-*`
3
+ * custom property, so a stylesheet can set the same things — the prop exists so
4
+ * an app that themes in JS doesn't have to write CSS to do it. */
5
+ export interface AgentColors {
6
+ /** Hairlines: table cells, code borders, the player's ring. */
7
+ border?: string;
8
+ /** Secondary text: quotes, the clock, "transcribing…". */
9
+ muted?: string;
10
+ /** Filled surfaces: code blocks, table headers, the play button. */
11
+ surface?: string;
12
+ }
13
+ /** Syntax colours, on top of the shared three. */
14
+ export interface SyntaxColors {
15
+ comment?: string;
16
+ keyword?: string;
17
+ string?: string;
18
+ number?: string;
19
+ title?: string;
20
+ type?: string;
21
+ }
22
+ /** Inline custom properties — the app speaking directly, which outranks every
23
+ * stylesheet without anyone reaching for `!important`. */
24
+ export declare function colorStyle(colors: Partial<AgentColors & SyntaxColors> | undefined): CSSProperties | undefined;
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Escapes every `$` that is currency, leaving TeX delimiters alone.
3
+ *
4
+ * Walks the text rather than running a regex over it, because the decision
5
+ * depends on context a regex cannot see: fenced blocks, inline code and `$$…$$`
6
+ * display maths all have to pass through untouched, or this would corrupt a
7
+ * code sample or break a formula the agent meant.
8
+ */
9
+ export declare function escapeCurrency(markdown: string): string;
@@ -0,0 +1,11 @@
1
+ export type { AgentMarkdownProps } from "./agent-markdown.js";
2
+ export { AgentMarkdown } from "./agent-markdown.js";
3
+ export type { AgentColors, SyntaxColors } from "./colors.js";
4
+ export type { RecordingWaveProps } from "./recording-wave.js";
5
+ export { RecordingWave } from "./recording-wave.js";
6
+ export type { RecorderState, UseRecorderResult } from "./use-recorder.js";
7
+ export { useRecorder } from "./use-recorder.js";
8
+ export type { UseTranscriptAnchorOptions, UseTranscriptAnchorResult, } from "./use-transcript-anchor.js";
9
+ export { useTranscriptAnchor } from "./use-transcript-anchor.js";
10
+ export type { VoiceMessageLabels, VoiceMessageProps } from "./voice-message.js";
11
+ export { VoiceMessage } from "./voice-message.js";