@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.
@@ -0,0 +1,496 @@
1
+ /* Styles for `@cubos/agent-sdk-react-dom`.
2
+ *
3
+ * Imported by the components themselves, so a bundler picks this up and the
4
+ * consumer imports nothing. Two properties make that safe to do in someone
5
+ * else's app:
6
+ *
7
+ * 1. **Nothing escapes.** Every selector is scoped to a class this package
8
+ * puts on its own wrapper — `.cubos-agent-sdk-react-dom` for what every component
9
+ * shares, `.cubos-agent-sdk-react-dom--<name>` for one component's own rules.
10
+ * 2. **Nothing wins an argument.** Everything sits in the `cubos-agent-sdk-react-dom`
11
+ * cascade layer and every selector is wrapped in `:where()`, which
12
+ * contributes no specificity. Unlayered app CSS beats a layer outright,
13
+ * and a bare `p {}` beats a zero-specificity rule — so these are defaults,
14
+ * not decisions. Nobody needs `!important` to restyle this package, which
15
+ * is the point: an app forced to shout at its dependency has already lost
16
+ * the argument, because `!important` inverts layer order and it would then
17
+ * be shouting at itself too.
18
+ *
19
+ * An app that puts its own CSS in layers should say where this one goes, once,
20
+ * above its own `@import`:
21
+ *
22
+ * @layer theme, base, cubos-agent-sdk-react-dom, components, utilities;
23
+ *
24
+ * Above the reset, so a `*` rule clearing borders and margins doesn't erase
25
+ * these defaults; below everything the app writes itself, so its own classes
26
+ * still win without anyone reaching for `!important`.
27
+ *
28
+ * Colours are `--cubos-agent-sdk-react-dom-*` custom properties. Surfaces mix from
29
+ * `currentColor`, so the same sheet reads correctly on a light page, a dark one
30
+ * or inside a coloured bubble; only the syntax palette is absolute, and an app
31
+ * that toggles theme itself overrides those six.
32
+ */
33
+
34
+ @layer cubos-agent-sdk-react-dom {
35
+ :where(.cubos-agent-sdk-react-dom) {
36
+ --cubos-agent-sdk-react-dom-border: color-mix(in srgb, currentColor 18%, transparent);
37
+ --cubos-agent-sdk-react-dom-muted: color-mix(in srgb, currentColor 65%, transparent);
38
+ --cubos-agent-sdk-react-dom-surface: color-mix(in srgb, currentColor 6%, transparent);
39
+ --cubos-agent-sdk-react-dom-comment: #5c6570;
40
+ --cubos-agent-sdk-react-dom-keyword: #b2144b;
41
+ --cubos-agent-sdk-react-dom-string: #0a5a2f;
42
+ --cubos-agent-sdk-react-dom-number: #0550ae;
43
+ --cubos-agent-sdk-react-dom-title: #6639ba;
44
+ --cubos-agent-sdk-react-dom-type: #8a4600;
45
+ --cubos-agent-sdk-react-dom-mono:
46
+ ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
47
+ }
48
+
49
+ @media (prefers-color-scheme: dark) {
50
+ :where(.cubos-agent-sdk-react-dom) {
51
+ --cubos-agent-sdk-react-dom-comment: #9aa4ae;
52
+ --cubos-agent-sdk-react-dom-keyword: #ff9492;
53
+ --cubos-agent-sdk-react-dom-string: #8ddb8c;
54
+ --cubos-agent-sdk-react-dom-number: #91cbff;
55
+ --cubos-agent-sdk-react-dom-title: #d2a8ff;
56
+ --cubos-agent-sdk-react-dom-type: #ffb757;
57
+ }
58
+ }
59
+
60
+ /* ── AgentMarkdown ─────────────────────────────────────────────────── */
61
+
62
+ /* The container may set `pre-wrap` so plain text keeps its line breaks. Once
63
+ parsed, the source newlines are already structure, and honouring them too
64
+ doubles every gap. A match on the child beats the parent's inherited value,
65
+ which is why zero specificity is enough here. */
66
+ :where(.cubos-agent-sdk-react-dom--markdown) {
67
+ white-space: normal;
68
+ }
69
+
70
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(p, ul, ol, blockquote, pre) {
71
+ margin: 0.75em 0;
72
+ }
73
+
74
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(h1, h2, h3, h4) {
75
+ margin: 1.2em 0 0.5em;
76
+ font-weight: 600;
77
+ line-height: 1.3;
78
+ }
79
+
80
+ /* An agent writing `# Title` means a section of its answer, not a page title;
81
+ at document scale it would shout over everything around it. */
82
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(h1) {
83
+ font-size: 1.3em;
84
+ }
85
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(h2) {
86
+ font-size: 1.15em;
87
+ }
88
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(h3, h4) {
89
+ font-size: 1em;
90
+ }
91
+
92
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(ul, ol) {
93
+ padding-left: 1.4em;
94
+ }
95
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(li) {
96
+ margin: 0.25em 0;
97
+ }
98
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(li) > :where(p) {
99
+ margin: 0.25em 0;
100
+ }
101
+
102
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(a) {
103
+ color: inherit;
104
+ text-decoration: underline;
105
+ text-underline-offset: 0.15em;
106
+ }
107
+
108
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(blockquote) {
109
+ padding-left: 0.9em;
110
+ border-left: 2px solid var(--cubos-agent-sdk-react-dom-border);
111
+ color: var(--cubos-agent-sdk-react-dom-muted);
112
+ }
113
+
114
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(hr) {
115
+ height: 0;
116
+ margin: 1.2em 0;
117
+ border: 0;
118
+ border-top: 1px solid var(--cubos-agent-sdk-react-dom-border);
119
+ }
120
+
121
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(code) {
122
+ font-family: var(--cubos-agent-sdk-react-dom-mono);
123
+ font-size: 0.875em;
124
+ }
125
+
126
+ /* Inline only — the `pre >` rule below resets it for blocks, which carry
127
+ their own padding and must not draw a second box per line. Same
128
+ specificity, so order is what separates them. */
129
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(:not(pre)) > :where(code) {
130
+ padding: 0.15em 0.35em;
131
+ border-radius: 0.3rem;
132
+ background: var(--cubos-agent-sdk-react-dom-surface);
133
+ border: 1px solid var(--cubos-agent-sdk-react-dom-border);
134
+ white-space: break-spaces;
135
+ }
136
+
137
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(pre) {
138
+ overflow-x: auto;
139
+ padding: 0.75rem 0.9rem;
140
+ border-radius: 0.6rem;
141
+ background: var(--cubos-agent-sdk-react-dom-surface);
142
+ border: 1px solid var(--cubos-agent-sdk-react-dom-border);
143
+ /* A long line scrolls; wrapping code changes what it means to read it. */
144
+ white-space: pre;
145
+ -webkit-overflow-scrolling: touch;
146
+ }
147
+
148
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(pre) > :where(code) {
149
+ padding: 0;
150
+ background: none;
151
+ border: 0;
152
+ font-size: 0.85em;
153
+ line-height: 1.55;
154
+ }
155
+
156
+ /* Scrolled rather than shrunk: a wide table on a phone moves sideways instead
157
+ of squeezing every column into two characters. The scroller is a wrapper,
158
+ so the table stays a table — `display: block` on a `<table>` breaks row
159
+ layout. */
160
+ :where(.cubos-agent-sdk-react-dom__table) {
161
+ overflow-x: auto;
162
+ margin: 0.75em 0;
163
+ -webkit-overflow-scrolling: touch;
164
+ }
165
+
166
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(table) {
167
+ width: 100%;
168
+ border-collapse: collapse;
169
+ font-size: 0.95em;
170
+ }
171
+
172
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(th, td) {
173
+ padding: 0.4em 0.7em;
174
+ border: 1px solid var(--cubos-agent-sdk-react-dom-border);
175
+ text-align: left;
176
+ }
177
+
178
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(thead) {
179
+ background: var(--cubos-agent-sdk-react-dom-surface);
180
+ }
181
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(th) {
182
+ font-weight: 600;
183
+ }
184
+
185
+ /* KaTeX ships its own sizes; a display formula only needs room and a way out
186
+ when it is wider than the column. */
187
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(.katex-display) {
188
+ margin: 0.9em 0;
189
+ padding: 0.2em 0;
190
+ overflow-x: auto;
191
+ overflow-y: hidden;
192
+ }
193
+
194
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(.hljs-comment, .hljs-quote) {
195
+ color: var(--cubos-agent-sdk-react-dom-comment);
196
+ font-style: italic;
197
+ }
198
+
199
+ :where(.cubos-agent-sdk-react-dom--markdown)
200
+ :where(.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-doctag, .hljs-name, .hljs-tag) {
201
+ color: var(--cubos-agent-sdk-react-dom-keyword);
202
+ }
203
+
204
+ :where(.cubos-agent-sdk-react-dom--markdown)
205
+ :where(.hljs-string, .hljs-regexp, .hljs-addition, .hljs-attribute) {
206
+ color: var(--cubos-agent-sdk-react-dom-string);
207
+ }
208
+
209
+ :where(.cubos-agent-sdk-react-dom--markdown)
210
+ :where(
211
+ .hljs-number,
212
+ .hljs-attr,
213
+ .hljs-variable,
214
+ .hljs-template-variable,
215
+ .hljs-selector-attr,
216
+ .hljs-property
217
+ ) {
218
+ color: var(--cubos-agent-sdk-react-dom-number);
219
+ }
220
+
221
+ :where(.cubos-agent-sdk-react-dom--markdown)
222
+ :where(.hljs-title, .hljs-section, .hljs-selector-id, .hljs-selector-class) {
223
+ color: var(--cubos-agent-sdk-react-dom-title);
224
+ }
225
+
226
+ :where(.cubos-agent-sdk-react-dom--markdown)
227
+ :where(.hljs-built_in, .hljs-type, .hljs-symbol, .hljs-bullet, .hljs-params) {
228
+ color: var(--cubos-agent-sdk-react-dom-type);
229
+ }
230
+
231
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(.hljs-meta, .hljs-deletion) {
232
+ color: var(--cubos-agent-sdk-react-dom-meta, var(--cubos-agent-sdk-react-dom-comment));
233
+ }
234
+
235
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(.hljs-emphasis) {
236
+ font-style: italic;
237
+ }
238
+ :where(.cubos-agent-sdk-react-dom--markdown) :where(.hljs-strong) {
239
+ font-weight: 600;
240
+ }
241
+
242
+ /* A reply is one block of prose: the gap around it belongs to whatever
243
+ contains it. Last on purpose — everything here has the same specificity, so
244
+ only order decides. */
245
+ :where(.cubos-agent-sdk-react-dom--markdown) > :first-child {
246
+ margin-top: 0;
247
+ }
248
+ :where(.cubos-agent-sdk-react-dom--markdown) > :last-child {
249
+ margin-bottom: 0;
250
+ }
251
+
252
+ /* ── VoiceMessage ──────────────────────────────────────────────────── */
253
+
254
+ /* Takes the width of whatever holds it — the container decides how wide a
255
+ voice message is, and a bubble that caps itself caps the player with it. */
256
+ :where(.cubos-agent-sdk-react-dom--voice) {
257
+ display: flex;
258
+ flex-direction: column;
259
+ gap: 0.35rem;
260
+ width: 100%;
261
+ }
262
+
263
+ :where(.cubos-agent-sdk-react-dom__voice-row) {
264
+ display: flex;
265
+ align-items: center;
266
+ gap: 0.6rem;
267
+ }
268
+
269
+ /* Bare hit target — everything visible is on the span inside it. A reset owns
270
+ `button`, and in an app that loads this sheet before its own (no layer
271
+ statement, nothing to order it by) the reset would win. No reset claims a
272
+ `span`. */
273
+ :where(.cubos-agent-sdk-react-dom__voice-play) {
274
+ flex: none;
275
+ padding: 0;
276
+ border: 0;
277
+ background: none;
278
+ color: inherit;
279
+ cursor: pointer;
280
+ }
281
+
282
+ /* An inset shadow rather than a border: a reset that clears `border` on `*`
283
+ would take the ring with it in an app that puts this layer below its own
284
+ base. The shadow is nobody's reset. */
285
+ :where(.cubos-agent-sdk-react-dom__voice-face) {
286
+ display: grid;
287
+ place-items: center;
288
+ width: 2rem;
289
+ height: 2rem;
290
+ border-radius: 50%;
291
+ background: var(--cubos-agent-sdk-react-dom-surface);
292
+ box-shadow: inset 0 0 0 1px var(--cubos-agent-sdk-react-dom-border);
293
+ }
294
+
295
+ :where(.cubos-agent-sdk-react-dom__voice-play):disabled {
296
+ cursor: default;
297
+ }
298
+
299
+ :where(.cubos-agent-sdk-react-dom__voice-play):disabled
300
+ :where(.cubos-agent-sdk-react-dom__voice-face) {
301
+ opacity: 0.5;
302
+ }
303
+
304
+ /* The bars and the slider share one box: the slider sits on top, invisible
305
+ but hit-testable, so dragging the waveform seeks and a keyboard still gets
306
+ a real control. */
307
+ /* The floor is what keeps the player usable in a container that has no width
308
+ of its own: the waveform is absolutely positioned, so intrinsic sizing
309
+ measures the track as empty and a shrink-to-fit bubble would close around
310
+ it. 8rem still leaves the whole row inside the narrowest phone bubble. */
311
+ :where(.cubos-agent-sdk-react-dom__voice-track) {
312
+ position: relative;
313
+ flex: 1;
314
+ min-width: 8rem;
315
+ height: 1.75rem;
316
+ }
317
+
318
+ :where(.cubos-agent-sdk-react-dom__voice-wave) {
319
+ position: absolute;
320
+ inset: 0;
321
+ display: flex;
322
+ align-items: center;
323
+ gap: 2px;
324
+ pointer-events: none;
325
+ }
326
+
327
+ /* Before the clip is decoded there is no shape to draw, so the track is a
328
+ plain progress bar rather than an invented waveform. */
329
+ :where(.cubos-agent-sdk-react-dom__voice-line) {
330
+ position: absolute;
331
+ inset: 50% 0 auto;
332
+ height: 3px;
333
+ transform: translateY(-50%);
334
+ border-radius: 999px;
335
+ background: var(--cubos-agent-sdk-react-dom-border);
336
+ overflow: hidden;
337
+ }
338
+
339
+ :where(.cubos-agent-sdk-react-dom__voice-line-fill) {
340
+ display: block;
341
+ height: 100%;
342
+ border-radius: inherit;
343
+ background: currentColor;
344
+ opacity: 0.75;
345
+ }
346
+
347
+ /* The live meter. Shares the played waveform's bar language so a recording
348
+ in progress and the message it becomes read as the same object. */
349
+ :where(.cubos-agent-sdk-react-dom__meter) {
350
+ display: flex;
351
+ flex: 1;
352
+ align-items: center;
353
+ gap: 2px;
354
+ height: 1.5rem;
355
+ /* Same reason as the player's track: absolutely nothing here has an
356
+ intrinsic width, and a shrink-to-fit parent would close around it. */
357
+ min-width: 4rem;
358
+ }
359
+
360
+ :where(.cubos-agent-sdk-react-dom__meter-bar) {
361
+ flex: 1;
362
+ min-width: 2px;
363
+ border-radius: 1px;
364
+ background: currentColor;
365
+ opacity: 0.75;
366
+ /* The reading arrives every 50ms; the eye wants a line, not a strobe. */
367
+ transition: height 90ms linear;
368
+ }
369
+
370
+ @media (prefers-reduced-motion: reduce) {
371
+ :where(.cubos-agent-sdk-react-dom__meter-bar) {
372
+ transition: none;
373
+ }
374
+ }
375
+
376
+ /* Nothing is arriving. Drawn as absence rather than as alarm — the sentence
377
+ beside it is the app's, and two red things saying one thing is noise. */
378
+ :where(.cubos-agent-sdk-react-dom__meter--silent .cubos-agent-sdk-react-dom__meter-bar) {
379
+ opacity: 0.3;
380
+ }
381
+
382
+ :where(.cubos-agent-sdk-react-dom__voice-bar) {
383
+ flex: 1;
384
+ min-width: 2px;
385
+ border-radius: 1px;
386
+ background: var(--cubos-agent-sdk-react-dom-border);
387
+ }
388
+
389
+ :where(.cubos-agent-sdk-react-dom__voice-bar--played) {
390
+ background: currentColor;
391
+ opacity: 0.75;
392
+ }
393
+
394
+ /* Interaction only — the playhead below is drawn by us. The control stays a
395
+ real range input for the keyboard, the pointer and the ARIA; what it must
396
+ not do is paint. */
397
+ :where(.cubos-agent-sdk-react-dom__voice-seek) {
398
+ position: absolute;
399
+ inset: 0;
400
+ width: 100%;
401
+ height: 100%;
402
+ margin: 0;
403
+ appearance: none;
404
+ -webkit-appearance: none;
405
+ background: none;
406
+ cursor: pointer;
407
+ }
408
+
409
+ /* The only two rules here that carry specificity, and neither has a choice:
410
+ a vendor pseudo-element inside `:where()` is dropped by the engine.
411
+ Hiding the thumb through `opacity` on the input instead does not survive
412
+ Tailwind, whose preflight sets `opacity: 1` on every `input` from a layer
413
+ that outranks this one. */
414
+ .cubos-agent-sdk-react-dom__voice-seek::-webkit-slider-thumb {
415
+ appearance: none;
416
+ -webkit-appearance: none;
417
+ width: 0.55rem;
418
+ height: 0.55rem;
419
+ border: none;
420
+ background: transparent;
421
+ }
422
+
423
+ .cubos-agent-sdk-react-dom__voice-seek::-moz-range-thumb {
424
+ width: 0.55rem;
425
+ height: 0.55rem;
426
+ border: none;
427
+ background: transparent;
428
+ }
429
+
430
+ :where(.cubos-agent-sdk-react-dom__voice-seek):disabled {
431
+ cursor: default;
432
+ }
433
+
434
+ :where(.cubos-agent-sdk-react-dom__voice-head) {
435
+ position: absolute;
436
+ top: 50%;
437
+ width: 0.55rem;
438
+ height: 0.55rem;
439
+ border-radius: 50%;
440
+ background: currentColor;
441
+ transform: translate(-50%, -50%);
442
+ pointer-events: none;
443
+ }
444
+
445
+ /* The ring belongs on the track, since the control itself is transparent. */
446
+ :where(.cubos-agent-sdk-react-dom__voice-play:focus-visible)
447
+ :where(.cubos-agent-sdk-react-dom__voice-face) {
448
+ outline: 2px solid color-mix(in srgb, currentColor 45%, transparent);
449
+ outline-offset: 2px;
450
+ }
451
+
452
+ :where(.cubos-agent-sdk-react-dom__voice-track:has(:focus-visible)) {
453
+ outline: 2px solid color-mix(in srgb, currentColor 45%, transparent);
454
+ outline-offset: 2px;
455
+ border-radius: 0.2rem;
456
+ }
457
+
458
+ :where(.cubos-agent-sdk-react-dom__voice-time) {
459
+ flex: none;
460
+ font-size: 0.8em;
461
+ font-variant-numeric: tabular-nums;
462
+ color: var(--cubos-agent-sdk-react-dom-muted);
463
+ }
464
+
465
+ :where(.cubos-agent-sdk-react-dom__voice-disclosure) {
466
+ display: inline-flex;
467
+ align-items: center;
468
+ gap: 0.3rem;
469
+ align-self: flex-start;
470
+ padding: 0;
471
+ border: 0;
472
+ background: none;
473
+ color: var(--cubos-agent-sdk-react-dom-muted);
474
+ font: inherit;
475
+ font-size: 0.8em;
476
+ cursor: pointer;
477
+ }
478
+
479
+ :where(.cubos-agent-sdk-react-dom__voice-transcript) {
480
+ margin: 0;
481
+ font-size: 0.95em;
482
+ }
483
+
484
+ :where(.cubos-agent-sdk-react-dom__voice-pending) {
485
+ margin: 0;
486
+ font-size: 0.8em;
487
+ color: var(--cubos-agent-sdk-react-dom-muted);
488
+ font-style: italic;
489
+ }
490
+
491
+ :where(.cubos-agent-sdk-react-dom__voice-error) {
492
+ margin: 0;
493
+ font-size: 0.8em;
494
+ color: var(--cubos-agent-sdk-react-dom-muted);
495
+ }
496
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * `unsupported` is settled after mount, never during render: the check reads
3
+ * `navigator`, and a server render has none.
4
+ */
5
+ export type RecorderState = "idle" | "requesting" | "recording" | "unsupported";
6
+ export interface UseRecorderResult {
7
+ state: RecorderState;
8
+ /** Seconds elapsed in the current take, for a live timer. */
9
+ seconds: number;
10
+ /**
11
+ * The microphone's signal over the last couple of seconds, oldest first and
12
+ * already scaled 0..1 for drawing. Fixed length, so it is a window that
13
+ * scrolls rather than a list that grows — pass it to `RecordingWave`.
14
+ *
15
+ * All zeros before anything is heard, which is the same thing a muted device
16
+ * produces: `silent` is what tells those apart, because two seconds of flat
17
+ * bars is ambiguous and a sentence is not.
18
+ */
19
+ levels: number[];
20
+ /**
21
+ * Nothing above the noise floor for long enough that it is worth saying so.
22
+ *
23
+ * A muted microphone records perfectly: the clip arrives, the transcription
24
+ * comes back empty, and the agent answers a message nobody managed to send.
25
+ * The only moment that is cheap to fix is while the person is still talking.
26
+ */
27
+ silent: boolean;
28
+ /** Denied permission, no microphone, or a MediaRecorder failure. */
29
+ error: Error | null;
30
+ start: () => Promise<void>;
31
+ /** Resolves with the clip, or null if nothing was captured. */
32
+ stop: () => Promise<Blob | null>;
33
+ /** Drops the take and releases the microphone. */
34
+ cancel: () => void;
35
+ }
36
+ /**
37
+ * One meter reading from a chunk of time-domain samples.
38
+ *
39
+ * RMS rather than peak: peak jumps on a single click and reads as speech, and
40
+ * what the meter is for is telling "someone is talking" from "nothing is
41
+ * arriving". Bytes are unsigned with silence at 128, which is the format
42
+ * `getByteTimeDomainData` writes.
43
+ */
44
+ export declare function levelOf(samples: Uint8Array): number;
45
+ /**
46
+ * Microphone capture as a Blob, ready for `sendAudio`.
47
+ *
48
+ * The stream is released on every exit path — stop, cancel, unmount, a failure
49
+ * — because a live `MediaStream` keeps the browser's recording indicator lit
50
+ * long after the UI has moved on, and nothing on screen explains why.
51
+ *
52
+ * The container is probed rather than chosen: Chrome and Firefox record WebM,
53
+ * Safari only ever produces MP4/AAC, and asking Safari for WebM fails silently
54
+ * instead of raising — a clip that is simply never assembled.
55
+ */
56
+ export declare function useRecorder(): UseRecorderResult;
@@ -0,0 +1,67 @@
1
+ import { type RefObject } from "react";
2
+ export interface UseTranscriptAnchorOptions {
3
+ /**
4
+ * Anything that changes when the tail grows — the last event's `seq`, the
5
+ * message count, whether the typing indicator is up. Only a trigger; the
6
+ * value is never read.
7
+ */
8
+ tailKey: unknown;
9
+ /** There is older history to page in when the reader scrolls up. */
10
+ hasOlder?: boolean;
11
+ /**
12
+ * Fetches the previous page; resolves with how many items were prepended.
13
+ * Zero releases the anchor, so a page that came back empty does not freeze
14
+ * the viewport.
15
+ */
16
+ loadOlder?: () => Promise<number>;
17
+ /**
18
+ * How close to the end still counts as being at it, in pixels. Small on
19
+ * purpose: this reads as intent, not as a zone in which scrolling up is
20
+ * ignored.
21
+ */
22
+ atBottomPx?: number;
23
+ /** How close to the top counts as asking for more. */
24
+ loadOlderPx?: number;
25
+ }
26
+ export interface UseTranscriptAnchorResult {
27
+ /**
28
+ * The scrolling box. Yours to style — this attaches no class and renders no
29
+ * element.
30
+ */
31
+ scrollRef: RefObject<HTMLDivElement | null>;
32
+ /**
33
+ * Hang it on whatever wraps the items. A callback ref, so it holds however
34
+ * the caller mounts and unmounts that wrapper.
35
+ */
36
+ contentRef: (node: HTMLElement | null) => void;
37
+ onScroll: () => void;
38
+ /** Whether the viewport is at the end — for a "jump to latest" affordance. */
39
+ isAtBottom: boolean;
40
+ scrollToEnd: () => void;
41
+ }
42
+ /**
43
+ * The scroll behaviour of a bottom-anchored transcript, without any of its
44
+ * looks: it owns no element, adds no class, and renders nothing. You write the
45
+ * markup and hang two refs off it.
46
+ *
47
+ * Four behaviours, each of which was a bug before it was a rule:
48
+ *
49
+ * 1. **Follow only if already following.** The intent is recorded when the
50
+ * reader scrolls, never measured after an update — by then the content has
51
+ * grown, so someone sitting exactly at the end is suddenly "far" from it by
52
+ * the height of whatever just arrived.
53
+ * 2. **Follow when the content grows, not when React re-renders.** A transcript
54
+ * grows for reasons no state changed: an image decodes, a font swaps, a
55
+ * disclosure opens, cards lay out a frame late. Keyed on props alone, the
56
+ * scroll runs against the height at commit time and leaves the last few
57
+ * hundred pixels — the ones the answer is written in — below the fold.
58
+ * 3. **Hold the reader's place when paging backwards.** Prepending grows the
59
+ * content upward while `scrollTop` stays put, which reads as the viewport
60
+ * jumping. The correction runs before paint, against a height measured
61
+ * *before* the commit — and on every commit, because prepending is the one
62
+ * change that leaves the tail, and so `tailKey`, untouched.
63
+ * 4. **Keep pulling while the content is shorter than the viewport.** A short
64
+ * first page can leave the box unscrollable, and then no scroll event ever
65
+ * fires to ask for the rest.
66
+ */
67
+ export declare function useTranscriptAnchor(options: UseTranscriptAnchorOptions): UseTranscriptAnchorResult;
@@ -0,0 +1,53 @@
1
+ import { type AgentColors } from "./colors.js";
2
+ import "./styles.css";
3
+ export interface VoiceMessageLabels {
4
+ play: string;
5
+ pause: string;
6
+ /** The disclosure that opens the transcript. */
7
+ transcript: string;
8
+ /** Shown in place of the transcript while STT is still running. */
9
+ transcribing: string;
10
+ /** Shown when STT finished and heard nothing — a silent recording. */
11
+ noSpeech: string;
12
+ loadFailed: string;
13
+ }
14
+ export interface VoiceMessageProps {
15
+ /**
16
+ * Fetches the clip, once, on first play. A `Blob` rather than a URL because
17
+ * the bytes usually sit behind a bearer token, which an `<audio src>` cannot
18
+ * carry.
19
+ */
20
+ loadBytes: (signal: AbortSignal) => Promise<Blob>;
21
+ /**
22
+ * The words, once the agent's STT model has produced them.
23
+ *
24
+ * The three states are distinct and a client has all three: absent while STT
25
+ * is still running, the empty string once it finished and heard nothing, and
26
+ * the text otherwise. Reading empty as "not yet" is what left a silent
27
+ * recording saying "Transcribing…" for good — `Message.transcribed` in
28
+ * `@cubos/agent-sdk` is the flag that tells them apart.
29
+ */
30
+ transcript?: string;
31
+ /** Size of the clip, shown until it has been loaded and measured. */
32
+ bytes?: number;
33
+ /** Open the transcript from the start. Collapsed by default — the player is
34
+ * the message; the words are there when you want them. */
35
+ defaultTranscriptOpen?: boolean;
36
+ labels?: Partial<VoiceMessageLabels>;
37
+ /** Overrides the palette for this player, as inline custom properties — the
38
+ * same tokens `styles.css` declares, without writing CSS. */
39
+ colors?: AgentColors;
40
+ className?: string;
41
+ }
42
+ /**
43
+ * A voice message: play control, waveform, elapsed time, and the transcription
44
+ * folded away underneath — the shape Slack and WhatsApp made familiar.
45
+ *
46
+ * The clip is fetched when the player scrolls into view, and the waveform is
47
+ * drawn from it before anyone presses anything. Not on mount: a long
48
+ * conversation would pull down every recording in it, most of which nobody will
49
+ * ever open. Not on first play either — that left the player showing a file
50
+ * size where its shape should be, and the shape is most of what a voice message
51
+ * says at a glance.
52
+ */
53
+ export declare function VoiceMessage({ loadBytes, transcript, bytes, defaultTranscriptOpen, labels, colors, className, }: VoiceMessageProps): import("react").JSX.Element;