@opengeni/react 0.42.1 → 0.44.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.
Files changed (49) hide show
  1. package/dist/{chunk-SJKT4TKW.js → chunk-23EJ676W.js} +3 -1
  2. package/dist/chunk-23EJ676W.js.map +1 -0
  3. package/dist/{chunk-UWYTCQWW.js → chunk-KC27K42G.js} +92 -21
  4. package/dist/{chunk-UWYTCQWW.js.map → chunk-KC27K42G.js.map} +1 -1
  5. package/dist/{chunk-KR2SK5GJ.js → chunk-KG5F2OKE.js} +260 -93
  6. package/dist/chunk-KG5F2OKE.js.map +1 -0
  7. package/dist/{chunk-4IJCL7YO.js → chunk-LWR4MXSS.js} +4 -1
  8. package/dist/chunk-LWR4MXSS.js.map +1 -0
  9. package/dist/{chunk-JALF5FI3.js → chunk-SRFUT2ZU.js} +2 -2
  10. package/dist/{chunk-WZT5G5OR.js → chunk-U6K24XQD.js} +5 -4
  11. package/dist/{chunk-WZT5G5OR.js.map → chunk-U6K24XQD.js.map} +1 -1
  12. package/dist/components/chat-composer.d.ts +5 -1
  13. package/dist/components/composer-transcription-control.d.ts +3 -1
  14. package/dist/components/composer.d.ts +6 -4
  15. package/dist/components/session-chrome.d.ts +1 -1
  16. package/dist/composer.js +3 -3
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +69 -20
  19. package/dist/index.js.map +1 -1
  20. package/dist/model-policy.d.ts +2 -0
  21. package/dist/model-policy.js +1 -1
  22. package/dist/realtime/realtime-control.d.ts +29 -1
  23. package/dist/realtime.d.ts +1 -1
  24. package/dist/realtime.js +269 -151
  25. package/dist/realtime.js.map +1 -1
  26. package/dist/session-ui.js +2 -2
  27. package/dist/session.js +2 -2
  28. package/dist/timeline/index.d.ts +1 -1
  29. package/dist/timeline/parsers.d.ts +13 -8
  30. package/package.json +2 -2
  31. package/src/components/chat-composer.tsx +58 -19
  32. package/src/components/composer-transcription-control.tsx +194 -165
  33. package/src/components/composer.tsx +78 -14
  34. package/src/components/model-policy-picker.tsx +7 -2
  35. package/src/components/session-chrome.tsx +89 -78
  36. package/src/index.ts +2 -0
  37. package/src/model-policy.ts +4 -0
  38. package/src/realtime/realtime-control.tsx +307 -137
  39. package/src/realtime.ts +1 -0
  40. package/src/timeline/index.ts +2 -0
  41. package/src/timeline/parsers.ts +201 -19
  42. package/src/timeline/projection.ts +11 -0
  43. package/src/timeline/tool-renderers.tsx +7 -5
  44. package/src/timeline/turn-summary.tsx +7 -2
  45. package/styles/tokens.css +8 -5
  46. package/dist/chunk-4IJCL7YO.js.map +0 -1
  47. package/dist/chunk-KR2SK5GJ.js.map +0 -1
  48. package/dist/chunk-SJKT4TKW.js.map +0 -1
  49. /package/dist/{chunk-JALF5FI3.js.map → chunk-SRFUT2ZU.js.map} +0 -0
@@ -101,6 +101,8 @@ export type ComposerTranscriptionControlProps = {
101
101
  createRecordingStore?: (() => VoiceRecordingStore) | undefined;
102
102
  /** Test/embed seam. Production acquires a coordinated browser-document owner lease. */
103
103
  createOwnerId?: (() => string) | undefined;
104
+ /** Hide while realtime voice owns the mic — collapses with a layout-friendly exit. */
105
+ suppressed?: boolean | undefined;
104
106
  };
105
107
 
106
108
  const WAVEFORM_BARS = 18;
@@ -119,6 +121,7 @@ export function ComposerTranscriptionControl({
119
121
  className,
120
122
  createRecordingStore,
121
123
  createOwnerId,
124
+ suppressed = false,
122
125
  }: ComposerTranscriptionControlProps) {
123
126
  const composer = useChatComposer();
124
127
  const messages = { ...defaultMessages, ...overrides };
@@ -126,7 +129,7 @@ export function ComposerTranscriptionControl({
126
129
  client,
127
130
  workspaceId,
128
131
  capability,
129
- enabled: workspaceEnabled,
132
+ enabled: workspaceEnabled && !suppressed,
130
133
  value: composer.value,
131
134
  setValue: composer.setValue,
132
135
  focusInput: composer.focusInput,
@@ -135,6 +138,14 @@ export function ComposerTranscriptionControl({
135
138
  createOwnerId,
136
139
  });
137
140
  const { status } = transcription;
141
+
142
+ const cancelTranscription = transcription.cancel;
143
+ useEffect(() => {
144
+ if (!suppressed) return;
145
+ if (status === "recording" || status === "requesting-permission") {
146
+ cancelTranscription();
147
+ }
148
+ }, [cancelTranscription, status, suppressed]);
138
149
  const active =
139
150
  status === "requesting-permission" ||
140
151
  status === "recording" ||
@@ -181,179 +192,197 @@ export function ComposerTranscriptionControl({
181
192
  }
182
193
 
183
194
  return (
184
- <span
185
- className={cn("inline-flex min-w-0 items-center gap-1.5", className)}
186
- data-transcription-status={status}
187
- >
188
- <AnimatePresence mode="popLayout" initial={false}>
189
- {recoverable ? (
190
- <motion.span
191
- key="recovered"
192
- initial={{ opacity: 0, scale: 0.96 }}
193
- animate={{ opacity: 1, scale: 1 }}
194
- exit={{ opacity: 0, scale: 0.96 }}
195
- transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
196
- className={cn(
197
- "inline-flex h-8 min-w-0 items-center gap-1 rounded-og-md border border-og-border/80",
198
- "bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-11",
199
- )}
200
- >
201
- <span className="max-w-44 truncate text-og-xs text-og-fg-muted max-sm:max-w-28">
202
- {savedTranscript
203
- ? (errorMessage ?? messages.recoveredTranscript)
204
- : status === "error"
205
- ? (errorMessage ?? messages.errorRetryable)
206
- : messages.recovered}
207
- </span>
208
- <Tip tip={savedTranscript ? messages.insertRecoveredTranscript : messages.retry}>
209
- <button
210
- type="button"
211
- onClick={() =>
212
- savedTranscript
213
- ? void transcription.insertSavedTranscript()
214
- : transcription.retry()
215
- }
216
- aria-label={savedTranscript ? messages.insertRecoveredTranscript : messages.retry}
217
- className={cn(
218
- "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
219
- "bg-og-fg text-og-bg transition-colors duration-150 motion-reduce:transition-none",
220
- "hover:bg-og-fg-muted pointer-coarse:size-11",
221
- )}
222
- >
223
- {savedTranscript ? (
224
- <ClipboardPasteIcon className="size-3.5" />
225
- ) : (
226
- <RefreshCwIcon className="size-3.5" />
227
- )}
228
- </button>
229
- </Tip>
230
- <Tip tip={messages.discardRecovered}>
231
- <button
232
- type="button"
233
- onClick={() => void transcription.discard()}
234
- aria-label={messages.discardRecovered}
235
- className={cn(
236
- "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
237
- "text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
238
- "hover:bg-og-surface-3 hover:text-og-status-failed pointer-coarse:size-11",
239
- )}
240
- >
241
- <Trash2Icon className="size-3.5" />
242
- </button>
243
- </Tip>
244
- </motion.span>
245
- ) : active ? (
246
- <motion.span
247
- key={status === "transcribing" || status === "saving" ? "processing" : "capture"}
248
- initial={{ opacity: 0, scale: 0.96 }}
249
- animate={{ opacity: 1, scale: 1 }}
250
- exit={{ opacity: 0, scale: 0.96 }}
251
- transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
252
- className={cn(
253
- "inline-flex h-8 items-center gap-1 rounded-og-md border border-og-border/80",
254
- "bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-11",
255
- )}
256
- >
257
- {status === "requesting-permission" ? (
258
- <LoaderCircleIcon
259
- className="size-3.5 shrink-0 text-og-fg-muted animate-og-spin motion-reduce:animate-none"
260
- aria-hidden
261
- />
262
- ) : (
263
- <span className="flex items-center gap-1.5">
264
- {status === "recording" ? (
265
- <span
266
- aria-hidden
267
- className="size-1.5 shrink-0 rounded-full bg-og-status-failed animate-og-pulse motion-reduce:animate-none"
268
- />
269
- ) : null}
270
- <VoiceWaveform
271
- stream={status === "recording" ? transcription.stream : null}
272
- mode={status === "recording" ? "recording" : "transcribing"}
273
- />
274
- </span>
275
- )}
276
- {status === "recording" ? (
277
- <>
278
- <Tip tip={messages.cancel}>
279
- <button
195
+ <AnimatePresence initial={false}>
196
+ {suppressed ? null : (
197
+ <motion.span
198
+ key="composer-dictate"
199
+ initial={{ opacity: 0, width: 0 }}
200
+ animate={{ opacity: 1, width: "auto" }}
201
+ exit={{ opacity: 0, width: 0 }}
202
+ transition={{ duration: 0.36, ease: [0.22, 1, 0.36, 1] }}
203
+ className={cn("inline-flex min-w-0 overflow-hidden", className)}
204
+ data-transcription-status={status}
205
+ >
206
+ <span className="inline-flex min-w-0 items-center gap-1.5">
207
+ <AnimatePresence mode="popLayout" initial={false}>
208
+ {recoverable ? (
209
+ <motion.span
210
+ key="recovered"
211
+ initial={{ opacity: 0, scale: 0.96 }}
212
+ animate={{ opacity: 1, scale: 1 }}
213
+ exit={{ opacity: 0, scale: 0.96 }}
214
+ transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
215
+ className={cn(
216
+ "inline-flex h-8 min-w-0 items-center gap-1 rounded-og-md border border-og-border/80",
217
+ "bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-11",
218
+ )}
219
+ >
220
+ <span className="max-w-44 truncate text-og-xs text-og-fg-muted max-sm:max-w-28">
221
+ {savedTranscript
222
+ ? (errorMessage ?? messages.recoveredTranscript)
223
+ : status === "error"
224
+ ? (errorMessage ?? messages.errorRetryable)
225
+ : messages.recovered}
226
+ </span>
227
+ <Tip tip={savedTranscript ? messages.insertRecoveredTranscript : messages.retry}>
228
+ <button
229
+ type="button"
230
+ onClick={() =>
231
+ savedTranscript
232
+ ? void transcription.insertSavedTranscript()
233
+ : transcription.retry()
234
+ }
235
+ aria-label={
236
+ savedTranscript ? messages.insertRecoveredTranscript : messages.retry
237
+ }
238
+ className={cn(
239
+ "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
240
+ "bg-og-fg text-og-bg transition-colors duration-150 motion-reduce:transition-none",
241
+ "hover:bg-og-fg-muted pointer-coarse:size-11",
242
+ )}
243
+ >
244
+ {savedTranscript ? (
245
+ <ClipboardPasteIcon className="size-3.5" />
246
+ ) : (
247
+ <RefreshCwIcon className="size-3.5" />
248
+ )}
249
+ </button>
250
+ </Tip>
251
+ <Tip tip={messages.discardRecovered}>
252
+ <button
253
+ type="button"
254
+ onClick={() => void transcription.discard()}
255
+ aria-label={messages.discardRecovered}
256
+ className={cn(
257
+ "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
258
+ "text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
259
+ "hover:bg-og-surface-3 hover:text-og-status-failed pointer-coarse:size-11",
260
+ )}
261
+ >
262
+ <Trash2Icon className="size-3.5" />
263
+ </button>
264
+ </Tip>
265
+ </motion.span>
266
+ ) : active ? (
267
+ <motion.span
268
+ key={status === "transcribing" || status === "saving" ? "processing" : "capture"}
269
+ initial={{ opacity: 0, scale: 0.96 }}
270
+ animate={{ opacity: 1, scale: 1 }}
271
+ exit={{ opacity: 0, scale: 0.96 }}
272
+ transition={{ duration: 0.16, ease: [0.22, 1, 0.36, 1] }}
273
+ className={cn(
274
+ "inline-flex h-8 items-center gap-1 rounded-og-md border border-og-border/80",
275
+ "bg-og-surface-2/70 pl-2 pr-1 pointer-coarse:h-11",
276
+ )}
277
+ >
278
+ {status === "requesting-permission" ? (
279
+ <LoaderCircleIcon
280
+ className="size-3.5 shrink-0 text-og-fg-muted animate-og-spin motion-reduce:animate-none"
281
+ aria-hidden
282
+ />
283
+ ) : (
284
+ <span className="flex items-center gap-1.5">
285
+ {status === "recording" ? (
286
+ <span
287
+ aria-hidden
288
+ className="size-1.5 shrink-0 rounded-full bg-og-status-failed animate-og-pulse motion-reduce:animate-none"
289
+ />
290
+ ) : null}
291
+ <VoiceWaveform
292
+ stream={status === "recording" ? transcription.stream : null}
293
+ mode={status === "recording" ? "recording" : "transcribing"}
294
+ />
295
+ </span>
296
+ )}
297
+ {status === "recording" ? (
298
+ <>
299
+ <Tip tip={messages.cancel}>
300
+ <button
301
+ type="button"
302
+ onClick={() => transcription.cancel()}
303
+ aria-label={messages.cancel}
304
+ aria-keyshortcuts="Escape"
305
+ className={cn(
306
+ "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
307
+ "text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
308
+ "hover:bg-og-surface-3 hover:text-og-fg pointer-coarse:size-11",
309
+ )}
310
+ >
311
+ <XIcon className="size-3.5" />
312
+ </button>
313
+ </Tip>
314
+ <Tip tip={messages.stop}>
315
+ <button
316
+ type="button"
317
+ onClick={() => transcription.stop()}
318
+ aria-label={messages.stop}
319
+ className={cn(
320
+ "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
321
+ "bg-og-fg text-og-bg transition-colors duration-150 motion-reduce:transition-none",
322
+ "hover:bg-og-fg-muted pointer-coarse:size-11",
323
+ )}
324
+ >
325
+ <SquareIcon className="size-2.5 fill-current" />
326
+ </button>
327
+ </Tip>
328
+ </>
329
+ ) : status === "transcribing" || status === "saving" ? (
330
+ <span className="og-shimmer-text px-1.5 text-og-xs font-medium whitespace-nowrap">
331
+ {status === "saving" ? messages.saving : messages.transcribing}
332
+ </span>
333
+ ) : (
334
+ <span className="px-1.5 text-og-xs text-og-fg-muted whitespace-nowrap">
335
+ {messages.requestingPermission}
336
+ </span>
337
+ )}
338
+ </motion.span>
339
+ ) : (
340
+ <Tip key="idle" tip={idleLabel}>
341
+ <motion.button
280
342
  type="button"
281
- onClick={() => transcription.cancel()}
282
- aria-label={messages.cancel}
283
- aria-keyshortcuts="Escape"
343
+ data-og-composer-dictate
344
+ initial={{ opacity: 0, scale: 0.96 }}
345
+ animate={{ opacity: 1, scale: 1 }}
346
+ exit={{ opacity: 0, scale: 0.96 }}
347
+ transition={{ duration: 0.14, ease: [0.22, 1, 0.36, 1] }}
348
+ onClick={start}
349
+ aria-label={idleLabel}
350
+ aria-pressed={false}
351
+ aria-disabled={unavailableMessage !== null}
284
352
  className={cn(
285
- "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
353
+ "inline-flex size-8 shrink-0 items-center justify-center rounded-og-md pointer-coarse:size-11",
286
354
  "text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
287
- "hover:bg-og-surface-3 hover:text-og-fg pointer-coarse:size-11",
288
- )}
289
- >
290
- <XIcon className="size-3.5" />
291
- </button>
292
- </Tip>
293
- <Tip tip={messages.stop}>
294
- <button
295
- type="button"
296
- onClick={() => transcription.stop()}
297
- aria-label={messages.stop}
298
- className={cn(
299
- "inline-flex size-7 shrink-0 items-center justify-center rounded-og-sm",
300
- "bg-og-fg text-og-bg transition-colors duration-150 motion-reduce:transition-none",
301
- "hover:bg-og-fg-muted pointer-coarse:size-11",
355
+ unavailableMessage
356
+ ? "cursor-not-allowed opacity-45"
357
+ : "hover:bg-og-surface-2 hover:text-og-fg",
302
358
  )}
303
359
  >
304
- <SquareIcon className="size-2.5 fill-current" />
305
- </button>
360
+ <MicIcon className="size-4" />
361
+ </motion.button>
306
362
  </Tip>
307
- </>
308
- ) : status === "transcribing" || status === "saving" ? (
309
- <span className="og-shimmer-text px-1.5 text-og-xs font-medium whitespace-nowrap">
310
- {status === "saving" ? messages.saving : messages.transcribing}
311
- </span>
312
- ) : (
313
- <span className="px-1.5 text-og-xs text-og-fg-muted whitespace-nowrap">
314
- {messages.requestingPermission}
315
- </span>
316
- )}
317
- </motion.span>
318
- ) : (
319
- <Tip key="idle" tip={idleLabel}>
320
- <motion.button
321
- type="button"
322
- initial={{ opacity: 0, scale: 0.96 }}
323
- animate={{ opacity: 1, scale: 1 }}
324
- exit={{ opacity: 0, scale: 0.96 }}
325
- transition={{ duration: 0.14, ease: [0.22, 1, 0.36, 1] }}
326
- onClick={start}
327
- aria-label={idleLabel}
328
- aria-pressed={false}
329
- aria-disabled={unavailableMessage !== null}
330
- className={cn(
331
- "inline-flex size-8 shrink-0 items-center justify-center rounded-og-md pointer-coarse:size-11",
332
- "text-og-fg-muted transition-colors duration-150 motion-reduce:transition-none",
333
- unavailableMessage
334
- ? "cursor-not-allowed opacity-45"
335
- : "hover:bg-og-surface-2 hover:text-og-fg",
336
363
  )}
364
+ </AnimatePresence>
365
+ {status === "error" && errorMessage && !recoverable ? (
366
+ <Tip tip={errorMessage}>
367
+ <span
368
+ aria-hidden="true"
369
+ className="max-w-40 truncate text-og-xs text-og-status-failed max-sm:max-w-24"
370
+ >
371
+ {errorMessage}
372
+ </span>
373
+ </Tip>
374
+ ) : null}
375
+ <span
376
+ className="sr-only"
377
+ role={status === "error" ? "alert" : "status"}
378
+ aria-live="polite"
337
379
  >
338
- <MicIcon className="size-4" />
339
- </motion.button>
340
- </Tip>
341
- )}
342
- </AnimatePresence>
343
- {status === "error" && errorMessage && !recoverable ? (
344
- <Tip tip={errorMessage}>
345
- <span
346
- aria-hidden="true"
347
- className="max-w-40 truncate text-og-xs text-og-status-failed max-sm:max-w-24"
348
- >
349
- {errorMessage}
380
+ {announcement}
381
+ </span>
350
382
  </span>
351
- </Tip>
352
- ) : null}
353
- <span className="sr-only" role={status === "error" ? "alert" : "status"} aria-live="polite">
354
- {announcement}
355
- </span>
356
- </span>
383
+ </motion.span>
384
+ )}
385
+ </AnimatePresence>
357
386
  );
358
387
  }
359
388
 
@@ -241,25 +241,84 @@ export type UseChatComposerControllerOptions = {
241
241
  };
242
242
 
243
243
  /**
244
- * Autosize a composer textarea up to `maxPx` without the classic height→0
245
- * measure collapse (that flash expands the timeline flex sibling and yanks
246
- * tip-follow while a stream is pinned).
244
+ * Off-document mirror for shrink/steady measure. Measuring with
245
+ * `height: auto` on the live `rows={1}` textarea collapses it for a layout
246
+ * frame, expands the flex timeline sibling, and yanks tip-follow the
247
+ * multi-line typing flicker that stops only once the box is capped.
248
+ */
249
+ let composerHeightMirror: HTMLTextAreaElement | null = null;
250
+
251
+ function measureComposerContentHeight(textarea: HTMLTextAreaElement): number {
252
+ if (typeof document === "undefined") {
253
+ return textarea.scrollHeight;
254
+ }
255
+ const width = textarea.clientWidth;
256
+ if (width <= 0) {
257
+ return textarea.offsetHeight;
258
+ }
259
+
260
+ let mirror = composerHeightMirror;
261
+ if (!mirror) {
262
+ mirror = document.createElement("textarea");
263
+ mirror.setAttribute("aria-hidden", "true");
264
+ mirror.tabIndex = -1;
265
+ mirror.rows = 1;
266
+ mirror.style.cssText =
267
+ "position:absolute;top:0;left:0;visibility:hidden;pointer-events:none;height:auto;min-height:0;max-height:none;overflow:hidden;z-index:-1;";
268
+ composerHeightMirror = mirror;
269
+ }
270
+
271
+ const style = getComputedStyle(textarea);
272
+ mirror.style.width = `${width}px`;
273
+ mirror.style.boxSizing = style.boxSizing;
274
+ mirror.style.font = style.font;
275
+ mirror.style.fontSize = style.fontSize;
276
+ mirror.style.fontFamily = style.fontFamily;
277
+ mirror.style.fontWeight = style.fontWeight;
278
+ mirror.style.fontStyle = style.fontStyle;
279
+ mirror.style.letterSpacing = style.letterSpacing;
280
+ mirror.style.lineHeight = style.lineHeight;
281
+ mirror.style.textTransform = style.textTransform;
282
+ mirror.style.paddingTop = style.paddingTop;
283
+ mirror.style.paddingRight = style.paddingRight;
284
+ mirror.style.paddingBottom = style.paddingBottom;
285
+ mirror.style.paddingLeft = style.paddingLeft;
286
+ mirror.style.borderTopWidth = style.borderTopWidth;
287
+ mirror.style.borderRightWidth = style.borderRightWidth;
288
+ mirror.style.borderBottomWidth = style.borderBottomWidth;
289
+ mirror.style.borderLeftWidth = style.borderLeftWidth;
290
+ mirror.style.borderStyle = style.borderStyle;
291
+ mirror.style.whiteSpace = style.whiteSpace;
292
+ mirror.style.wordBreak = style.wordBreak;
293
+ mirror.style.overflowWrap = style.overflowWrap;
294
+ mirror.value = textarea.value;
295
+
296
+ if (!mirror.isConnected) {
297
+ document.documentElement.appendChild(mirror);
298
+ }
299
+ return mirror.scrollHeight;
300
+ }
301
+
302
+ /**
303
+ * Autosize a composer textarea up to `maxPx`. Never writes intermediate
304
+ * `height: auto` / `0` on the live element — only the final pixel height.
305
+ *
306
+ * `measure` is injectable for unit tests; production always uses the off-DOM
307
+ * mirror so shrink/steady never collapses the laid-out box.
247
308
  */
248
309
  export function applyComposerTextareaHeight(
249
310
  textarea: HTMLTextAreaElement,
250
311
  maxPx: number = 220,
312
+ measure: (el: HTMLTextAreaElement) => number = measureComposerContentHeight,
251
313
  ): void {
252
- // Grow path: content already overflows — raise height, no measure collapse.
253
- if (textarea.scrollHeight > textarea.clientHeight + 1) {
254
- textarea.style.height = `${Math.min(textarea.scrollHeight, maxPx)}px`;
255
- return;
256
- }
257
- // Shrink / steady: `auto` measures natural height without flashing empty.
258
314
  const before = textarea.offsetHeight;
259
- textarea.style.height = "auto";
260
- const nextPx = Math.min(textarea.scrollHeight, maxPx);
315
+ // Overflow: content taller than the box — scrollHeight is the needed size.
316
+ // Fit/shrink: content fits (or box is oversized) — measure off-DOM.
317
+ const nextPx = Math.min(
318
+ textarea.scrollHeight > textarea.clientHeight + 1 ? textarea.scrollHeight : measure(textarea),
319
+ maxPx,
320
+ );
261
321
  if (Math.abs(nextPx - before) < 1) {
262
- textarea.style.height = `${before}px`;
263
322
  return;
264
323
  }
265
324
  textarea.style.height = `${nextPx}px`;
@@ -934,7 +993,8 @@ export const Footer = forwardRef<HTMLDivElement, ComposerFooterProps>(function C
934
993
  ref={ref}
935
994
  className={cn(
936
995
  "flex items-end gap-1.5 px-2 pb-2 pt-0.5 sm:px-2.5 sm:pb-2.5",
937
- "max-sm:items-center max-sm:gap-1",
996
+ // Mobile: one control row — never wrap into a second toolbar line.
997
+ "max-sm:flex-nowrap max-sm:items-center max-sm:gap-1",
938
998
  className,
939
999
  )}
940
1000
  />
@@ -987,7 +1047,11 @@ export const Actions = forwardRef<HTMLSpanElement, ComposerActionsProps>(functio
987
1047
  <span
988
1048
  {...props}
989
1049
  ref={ref}
990
- className={cn("ml-auto flex shrink-0 items-center gap-1.5", className)}
1050
+ className={cn(
1051
+ "ml-auto flex shrink-0 items-center gap-1.5",
1052
+ "max-sm:flex-nowrap max-sm:gap-1",
1053
+ className,
1054
+ )}
991
1055
  />
992
1056
  );
993
1057
  });
@@ -545,7 +545,7 @@ export function ModelPolicyPicker(props: ModelPolicyPickerProps) {
545
545
  disabled={props.disabled}
546
546
  aria-label={messages.label}
547
547
  className={cn(
548
- "inline-flex h-8 max-w-64 items-center gap-1 rounded-full border border-transparent px-2.5 text-xs text-og-fg-muted outline-none transition-colors hover:border-og-border hover:bg-og-surface-2 hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent/40 disabled:cursor-not-allowed disabled:opacity-50 max-sm:h-7 max-sm:max-w-[9rem] max-sm:px-2",
548
+ "inline-flex h-8 min-w-0 max-w-64 items-center gap-1 rounded-full border border-transparent px-2.5 text-xs text-og-fg-muted outline-none transition-colors hover:border-og-border hover:bg-og-surface-2 hover:text-og-fg focus-visible:ring-2 focus-visible:ring-og-accent/40 disabled:cursor-not-allowed disabled:opacity-50 max-sm:h-7 max-sm:max-w-[7.5rem] max-sm:px-2",
549
549
  props.className,
550
550
  )}
551
551
  >
@@ -556,7 +556,12 @@ export function ModelPolicyPicker(props: ModelPolicyPickerProps) {
556
556
  }
557
557
  className="text-og-fg"
558
558
  />
559
- <span className="truncate font-medium text-og-fg">{selected?.label ?? props.model}</span>
559
+ <span className="min-w-0 truncate font-medium text-og-fg max-sm:hidden">
560
+ {selected?.label ?? props.model}
561
+ </span>
562
+ <span className="min-w-0 truncate font-medium text-og-fg sm:hidden">
563
+ {selected?.shortLabel ?? selected?.label ?? props.model}
564
+ </span>
560
565
  <span className="max-sm:hidden">{labelReasoningEffort(props.effort)}</span>
561
566
  {props.latencyMode === "fast" ? (
562
567
  <ZapIcon