@oxy.so/bloom 3.2.1 → 3.4.0

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 (111) hide show
  1. package/docs/adoption-matrix.mdx +3 -3
  2. package/docs/ai-chat.mdx +31 -3
  3. package/docs/composer-panel.mdx +41 -6
  4. package/lib/commonjs/ai-chat/AiChatContainer.js +122 -14
  5. package/lib/commonjs/ai-chat/AiChatContainer.js.map +1 -1
  6. package/lib/commonjs/ai-chat/AiChatShell.js +46 -6
  7. package/lib/commonjs/ai-chat/AiChatShell.js.map +1 -1
  8. package/lib/commonjs/ai-chat/context.js +5 -1
  9. package/lib/commonjs/ai-chat/context.js.map +1 -1
  10. package/lib/commonjs/ai-chat/index.js +7 -0
  11. package/lib/commonjs/ai-chat/index.js.map +1 -1
  12. package/lib/commonjs/ai-chat/index.web.js +7 -0
  13. package/lib/commonjs/ai-chat/index.web.js.map +1 -1
  14. package/lib/commonjs/composer-panel/ComposerControls.js +61 -0
  15. package/lib/commonjs/composer-panel/ComposerControls.js.map +1 -1
  16. package/lib/commonjs/composer-panel/ComposerPanelBase.js +12 -4
  17. package/lib/commonjs/composer-panel/ComposerPanelBase.js.map +1 -1
  18. package/lib/commonjs/composer-panel/ComposerPillBase.js +186 -85
  19. package/lib/commonjs/composer-panel/ComposerPillBase.js.map +1 -1
  20. package/lib/commonjs/composer-panel/EffortSlider.js +24 -15
  21. package/lib/commonjs/composer-panel/EffortSlider.js.map +1 -1
  22. package/lib/commonjs/composer-panel/ModelPickerBase.js +15 -4
  23. package/lib/commonjs/composer-panel/ModelPickerBase.js.map +1 -1
  24. package/lib/module/ai-chat/AiChatContainer.js +122 -14
  25. package/lib/module/ai-chat/AiChatContainer.js.map +1 -1
  26. package/lib/module/ai-chat/AiChatShell.js +46 -6
  27. package/lib/module/ai-chat/AiChatShell.js.map +1 -1
  28. package/lib/module/ai-chat/context.js +5 -1
  29. package/lib/module/ai-chat/context.js.map +1 -1
  30. package/lib/module/ai-chat/index.js +1 -0
  31. package/lib/module/ai-chat/index.js.map +1 -1
  32. package/lib/module/ai-chat/index.web.js +1 -0
  33. package/lib/module/ai-chat/index.web.js.map +1 -1
  34. package/lib/module/composer-panel/ComposerControls.js +60 -0
  35. package/lib/module/composer-panel/ComposerControls.js.map +1 -1
  36. package/lib/module/composer-panel/ComposerPanelBase.js +13 -5
  37. package/lib/module/composer-panel/ComposerPanelBase.js.map +1 -1
  38. package/lib/module/composer-panel/ComposerPillBase.js +187 -86
  39. package/lib/module/composer-panel/ComposerPillBase.js.map +1 -1
  40. package/lib/module/composer-panel/EffortSlider.js +24 -15
  41. package/lib/module/composer-panel/EffortSlider.js.map +1 -1
  42. package/lib/module/composer-panel/ModelPickerBase.js +15 -4
  43. package/lib/module/composer-panel/ModelPickerBase.js.map +1 -1
  44. package/lib/typescript/commonjs/ai-chat/AiChatContainer.d.ts +28 -5
  45. package/lib/typescript/commonjs/ai-chat/AiChatContainer.d.ts.map +1 -1
  46. package/lib/typescript/commonjs/ai-chat/AiChatShell.d.ts +11 -2
  47. package/lib/typescript/commonjs/ai-chat/AiChatShell.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/ai-chat/context.d.ts +19 -1
  49. package/lib/typescript/commonjs/ai-chat/context.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/ai-chat/index.d.ts +3 -1
  51. package/lib/typescript/commonjs/ai-chat/index.d.ts.map +1 -1
  52. package/lib/typescript/commonjs/ai-chat/index.web.d.ts +3 -1
  53. package/lib/typescript/commonjs/ai-chat/index.web.d.ts.map +1 -1
  54. package/lib/typescript/commonjs/ai-chat/types.d.ts +101 -3
  55. package/lib/typescript/commonjs/ai-chat/types.d.ts.map +1 -1
  56. package/lib/typescript/commonjs/composer-panel/ComposerControls.d.ts +14 -0
  57. package/lib/typescript/commonjs/composer-panel/ComposerControls.d.ts.map +1 -1
  58. package/lib/typescript/commonjs/composer-panel/ComposerPanelBase.d.ts +3 -2
  59. package/lib/typescript/commonjs/composer-panel/ComposerPanelBase.d.ts.map +1 -1
  60. package/lib/typescript/commonjs/composer-panel/ComposerPillBase.d.ts +3 -2
  61. package/lib/typescript/commonjs/composer-panel/ComposerPillBase.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/composer-panel/EffortSlider.d.ts +10 -2
  63. package/lib/typescript/commonjs/composer-panel/EffortSlider.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/composer-panel/ModelPickerBase.d.ts.map +1 -1
  65. package/lib/typescript/commonjs/composer-panel/types.d.ts +70 -20
  66. package/lib/typescript/commonjs/composer-panel/types.d.ts.map +1 -1
  67. package/lib/typescript/module/ai-chat/AiChatContainer.d.ts +28 -5
  68. package/lib/typescript/module/ai-chat/AiChatContainer.d.ts.map +1 -1
  69. package/lib/typescript/module/ai-chat/AiChatShell.d.ts +11 -2
  70. package/lib/typescript/module/ai-chat/AiChatShell.d.ts.map +1 -1
  71. package/lib/typescript/module/ai-chat/context.d.ts +19 -1
  72. package/lib/typescript/module/ai-chat/context.d.ts.map +1 -1
  73. package/lib/typescript/module/ai-chat/index.d.ts +3 -1
  74. package/lib/typescript/module/ai-chat/index.d.ts.map +1 -1
  75. package/lib/typescript/module/ai-chat/index.web.d.ts +3 -1
  76. package/lib/typescript/module/ai-chat/index.web.d.ts.map +1 -1
  77. package/lib/typescript/module/ai-chat/types.d.ts +101 -3
  78. package/lib/typescript/module/ai-chat/types.d.ts.map +1 -1
  79. package/lib/typescript/module/composer-panel/ComposerControls.d.ts +14 -0
  80. package/lib/typescript/module/composer-panel/ComposerControls.d.ts.map +1 -1
  81. package/lib/typescript/module/composer-panel/ComposerPanelBase.d.ts +3 -2
  82. package/lib/typescript/module/composer-panel/ComposerPanelBase.d.ts.map +1 -1
  83. package/lib/typescript/module/composer-panel/ComposerPillBase.d.ts +3 -2
  84. package/lib/typescript/module/composer-panel/ComposerPillBase.d.ts.map +1 -1
  85. package/lib/typescript/module/composer-panel/EffortSlider.d.ts +10 -2
  86. package/lib/typescript/module/composer-panel/EffortSlider.d.ts.map +1 -1
  87. package/lib/typescript/module/composer-panel/ModelPickerBase.d.ts.map +1 -1
  88. package/lib/typescript/module/composer-panel/types.d.ts +70 -20
  89. package/lib/typescript/module/composer-panel/types.d.ts.map +1 -1
  90. package/package.json +1 -1
  91. package/src/__tests__/support/adoption-matrix.ts +664 -0
  92. package/src/__tests__/support/collision-fixture-barrel.ts +20 -0
  93. package/src/__tests__/support/composite.ts +68 -0
  94. package/src/__tests__/support/constructed-style-sheets.ts +68 -0
  95. package/src/__tests__/support/press-host.ts +30 -0
  96. package/src/__tests__/support/rendered-style.ts +99 -0
  97. package/src/__tests__/support/unread-hook-fixture.ts +33 -0
  98. package/src/ai-chat/AiChatContainer.tsx +137 -16
  99. package/src/ai-chat/AiChatShell.tsx +59 -5
  100. package/src/ai-chat/context.ts +19 -1
  101. package/src/ai-chat/index.ts +3 -0
  102. package/src/ai-chat/index.web.ts +3 -0
  103. package/src/ai-chat/types.ts +104 -3
  104. package/src/composer-panel/ComposerControls.tsx +54 -0
  105. package/src/composer-panel/ComposerPanelBase.tsx +13 -5
  106. package/src/composer-panel/ComposerPillBase.tsx +178 -66
  107. package/src/composer-panel/EffortSlider.tsx +38 -18
  108. package/src/composer-panel/ModelPickerBase.tsx +23 -7
  109. package/src/composer-panel/types.ts +71 -20
  110. package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +7682 -0
  111. package/src/theme/__tests__/fixtures/color-engine-golden.json +1 -0
@@ -23,7 +23,7 @@ import type { WebCssStyle } from '../styles/web-view-style';
23
23
  import { useTheme } from '../theme/use-theme';
24
24
  import { Text, TYPE_SCALE } from '../typography';
25
25
  import { AddMenu } from './AddMenu';
26
- import { MicButton, SendButton } from './ComposerControls';
26
+ import { MicButton, SendButton, StopButton } from './ComposerControls';
27
27
  import { useComposerPopover } from './context';
28
28
  import { EffortSlider } from './EffortSlider';
29
29
  import {
@@ -35,7 +35,7 @@ import {
35
35
  resolveComposerPalette,
36
36
  type ComposerPalette,
37
37
  } from './shared';
38
- import type { ComposerPillLabels, ComposerPillProps } from './types';
38
+ import type { ComposerPillLabels, ComposerPillProps, ModelPickerModel } from './types';
39
39
  import { dataHook, IS_WEB, useComposerWebCss } from './web-hooks';
40
40
 
41
41
  const EASE = Easing.bezier(0.25, 0.1, 0.25, 1);
@@ -43,7 +43,10 @@ const EASE_OUT = Easing.bezier(0, 0, 0.58, 1);
43
43
  const GLASS_MS = 480;
44
44
  /** `max-width: 639px`. */
45
45
  const COMPACT_WIDTH = 640;
46
+ /** The pill at one line: the field's 20px line box plus 8px padding either side, in a 52 box. */
46
47
  const PILL_HEIGHT = 52;
48
+ /** One line of `body-regular`, which is what the field was fixed at. */
49
+ const LINE_HEIGHT = 20;
47
50
 
48
51
  const DEFAULT_LABELS: Required<ComposerPillLabels> = {
49
52
  message: 'Message',
@@ -53,10 +56,12 @@ const DEFAULT_LABELS: Required<ComposerPillLabels> = {
53
56
  models: 'Models',
54
57
  modelGroup: 'Model',
55
58
  effort: 'Effort',
59
+ effortAuto: 'Auto',
56
60
  faster: 'Faster',
57
61
  smarter: 'Smarter',
58
62
  voice: 'Voice input',
59
63
  send: 'Send message',
64
+ stop: 'Stop generating',
60
65
  };
61
66
 
62
67
  /**
@@ -153,6 +158,13 @@ function ModelRow({
153
158
  }) {
154
159
  const [hovered, setHovered] = useState(false);
155
160
  const [focused, setFocused] = useState(false);
161
+ /**
162
+ * The field's measured content height, from `onContentSizeChange`.
163
+ *
164
+ * Measured rather than counted: a draft wraps, so the number of LINES is not
165
+ * the number of newlines, and only the field knows how its own text laid out.
166
+ */
167
+ const [contentHeight, setContentHeight] = useState(LINE_HEIGHT);
156
168
  return (
157
169
  <Pressable
158
170
  {...dataHook('bloomComposerRow')}
@@ -194,7 +206,10 @@ function ModelRow({
194
206
  *
195
207
  * models pt 4, label → rows 6: "Models" (pl 8, body-medium secondary), then a
196
208
  * radio row per model — p 8, radius 10, body-medium primary + 14px
197
- * radio, primary-hover while selected or hovered, 4 apart
209
+ * radio, primary-hover while selected or hovered, 4 apart. Rows key,
210
+ * match and report by id; a string entry is its own id
211
+ * effort only with stops to choose from — no `effortLevels`, no divider and
212
+ * no slider, rather than a slider that cannot commit
198
213
  * divider full bleed (−10 each side), 7 above, 12 below
199
214
  * effort "Effort <value>" (the value text-primary, blurring in on change),
200
215
  * Faster / Smarter (body-2-medium secondary, px 8 / pt 8 / pb 3) and
@@ -204,7 +219,7 @@ function ModelRow({
204
219
  */
205
220
  function ModelMenu({
206
221
  models,
207
- model,
222
+ modelId,
208
223
  onModelChange,
209
224
  effort,
210
225
  onEffortChange,
@@ -215,10 +230,10 @@ function ModelMenu({
215
230
  onTriggerLayout,
216
231
  testID,
217
232
  }: {
218
- models: ReadonlyArray<string>;
219
- model: string;
220
- onModelChange: (model: string) => void;
221
- effort: number;
233
+ models: ReadonlyArray<ModelPickerModel>;
234
+ modelId: string;
235
+ onModelChange: (modelId: string) => void;
236
+ effort: number | null;
222
237
  onEffortChange: (effort: number) => void;
223
238
  levels: ReadonlyArray<string>;
224
239
  labels: Required<ComposerPillLabels>;
@@ -231,6 +246,10 @@ function ModelMenu({
231
246
  const triggerRef = useRef<View>(null);
232
247
  const [open, setOpen] = useState(false);
233
248
  const [hovered, setHovered] = useState(false);
249
+ // An id with no entry (a model the lineup no longer carries) shows as itself
250
+ // rather than as an empty trigger.
251
+ const modelName = models.find((entry) => entry.id === modelId)?.name ?? modelId;
252
+ const hasEffort = levels.length > 0;
234
253
  const triggerStyle: WebCssStyle = {
235
254
  height: 32,
236
255
  flexShrink: 0,
@@ -254,7 +273,7 @@ function ModelMenu({
254
273
  testID={testID}
255
274
  {...dataHook('bloomComposerControl')}
256
275
  accessibilityRole="button"
257
- accessibilityLabel={model}
276
+ accessibilityLabel={modelName}
258
277
  aria-expanded={open}
259
278
  aria-haspopup="dialog"
260
279
  accessibilityState={{ expanded: open }}
@@ -264,7 +283,7 @@ function ModelMenu({
264
283
  onLayout={(event) => onTriggerLayout?.(event.nativeEvent.layout.width)}
265
284
  style={triggerStyle}>
266
285
  <Text variant="body-medium" numberOfLines={1} style={{ paddingLeft: 2, paddingRight: 2, color: palette.textSecondary }}>
267
- {model}
286
+ {modelName}
268
287
  </Text>
269
288
  <TurningChevron degrees={open ? 180 : 0} color={palette.iconSecondary} />
270
289
  </Pressable>
@@ -292,52 +311,63 @@ function ModelMenu({
292
311
  {labels.models}
293
312
  </Text>
294
313
  <View role="radiogroup" accessibilityLabel={labels.modelGroup} style={{ width: '100%', flexDirection: 'column', gap: 4 }}>
295
- {models.map((name) => (
314
+ {models.map((entry) => (
296
315
  <ModelRow
297
- key={name}
298
- name={name}
299
- selected={name === model}
316
+ key={entry.id}
317
+ name={entry.name}
318
+ selected={entry.id === modelId}
300
319
  palette={palette}
301
320
  onPress={() => {
302
- onModelChange(name);
321
+ onModelChange(entry.id);
303
322
  setOpen(false);
304
323
  }}
305
324
  />
306
325
  ))}
307
326
  </View>
308
327
  </View>
309
- <View style={{ height: 1, marginLeft: -10, marginRight: -10, marginTop: 7, marginBottom: 12, backgroundColor: palette.border }} />
310
- <View style={{ width: '100%', flexDirection: 'column' }}>
311
- <View style={{ flexDirection: 'row', alignItems: 'center', paddingLeft: 8 }}>
312
- <Text variant="body-medium" style={{ color: palette.textSecondary }}>
313
- {`${labels.effort} `}
314
- </Text>
315
- <EffortValue value={levels[effort] ?? ''} color={palette.text} />
316
- </View>
317
- <View style={{ width: '100%', flexDirection: 'column', gap: 4 }}>
318
- <View
319
- style={{
320
- width: '100%',
321
- flexDirection: 'row',
322
- alignItems: 'center',
323
- justifyContent: 'space-between',
324
- paddingLeft: 8,
325
- paddingRight: 8,
326
- paddingTop: 8,
327
- paddingBottom: 3,
328
- }}>
329
- <Text variant="body-2-medium" style={{ color: palette.textSecondary }}>
330
- {labels.faster}
331
- </Text>
332
- <Text variant="body-2-medium" style={{ color: palette.textSecondary }}>
333
- {labels.smarter}
334
- </Text>
335
- </View>
336
- <View style={{ width: '100%', paddingLeft: 8, paddingRight: 8, paddingBottom: 8 }}>
337
- <EffortSlider value={effort} onChange={onEffortChange} levels={levels} label={labels.effort} palette={palette} />
328
+ {hasEffort ? (
329
+ <>
330
+ <View style={{ height: 1, marginLeft: -10, marginRight: -10, marginTop: 7, marginBottom: 12, backgroundColor: palette.border }} />
331
+ <View style={{ width: '100%', flexDirection: 'column' }}>
332
+ <View style={{ flexDirection: 'row', alignItems: 'center', paddingLeft: 8 }}>
333
+ <Text variant="body-medium" style={{ color: palette.textSecondary }}>
334
+ {`${labels.effort} `}
335
+ </Text>
336
+ <EffortValue value={effort === null ? labels.effortAuto : (levels[effort] ?? labels.effortAuto)} color={palette.text} />
337
+ </View>
338
+ <View style={{ width: '100%', flexDirection: 'column', gap: 4 }}>
339
+ <View
340
+ style={{
341
+ width: '100%',
342
+ flexDirection: 'row',
343
+ alignItems: 'center',
344
+ justifyContent: 'space-between',
345
+ paddingLeft: 8,
346
+ paddingRight: 8,
347
+ paddingTop: 8,
348
+ paddingBottom: 3,
349
+ }}>
350
+ <Text variant="body-2-medium" style={{ color: palette.textSecondary }}>
351
+ {labels.faster}
352
+ </Text>
353
+ <Text variant="body-2-medium" style={{ color: palette.textSecondary }}>
354
+ {labels.smarter}
355
+ </Text>
356
+ </View>
357
+ <View style={{ width: '100%', paddingLeft: 8, paddingRight: 8, paddingBottom: 8 }}>
358
+ <EffortSlider
359
+ value={effort}
360
+ onChange={onEffortChange}
361
+ levels={levels}
362
+ label={labels.effort}
363
+ unsetLabel={labels.effortAuto}
364
+ palette={palette}
365
+ />
366
+ </View>
367
+ </View>
338
368
  </View>
339
- </View>
340
- </View>
369
+ </>
370
+ ) : null}
341
371
  </Popover>
342
372
  </>
343
373
  );
@@ -355,7 +385,8 @@ function ModelMenu({
355
385
  * unfocused on web, its last 32px fade into the pill
356
386
  * model the model menu trigger (see `ModelMenu`)
357
387
  * controls pl 6, gap 8: the bordered mic (dancing accent bars while
358
- * listening) and the `bg-button-primary` send disc
388
+ * listening) and the `bg-button-primary` send disc — which becomes
389
+ * the stop disc while `busy`, outside `disabled`'s reach
359
390
  *
360
391
  * `glass` drops the add, model and mic paint (480ms) onto frosted chips, so an
361
392
  * active `ComposerLoader` behind the pill shows through them.
@@ -365,6 +396,8 @@ export function ComposerPillBase({
365
396
  defaultValue = '',
366
397
  onValueChange,
367
398
  onSubmit,
399
+ onStop,
400
+ busy = false,
368
401
  disabled = false,
369
402
  placeholder = 'Ask me anything',
370
403
  compactPlaceholder = 'Ask me',
@@ -386,6 +419,7 @@ export function ComposerPillBase({
386
419
  inputRef,
387
420
  labels: labelOverrides,
388
421
  style,
422
+ maxLines = 8,
389
423
  testID,
390
424
  }: ComposerPillProps) {
391
425
  useComposerWebCss();
@@ -400,17 +434,28 @@ export function ComposerPillBase({
400
434
  defaultValue: defaultListening,
401
435
  onChange: onListeningChange,
402
436
  });
403
- const [modelName, setModel] = useControllableState<string>({
437
+ const lineup = useMemo(() => normalizeModels(models ?? []), [models]);
438
+ const [modelId, setModel] = useControllableState<string>({
404
439
  value: model,
405
- defaultValue: defaultModel ?? models?.[0] ?? '',
440
+ defaultValue: defaultModel ?? lineup[0]?.id ?? '',
406
441
  onChange: onModelChange,
407
442
  });
408
- const [effortValue, setEffort] = useControllableState<number>({
443
+ const [effortValue, setEffort] = useControllableState<number | null>({
409
444
  value: effort,
410
445
  defaultValue: defaultEffort,
411
- onChange: onEffortChange,
446
+ // The menu can only ever land on a stop; `null` is the caller's to hold.
447
+ onChange: (next) => {
448
+ if (next !== null) onEffortChange?.(next);
449
+ },
412
450
  });
413
451
  const [focused, setFocused] = useState(false);
452
+ /**
453
+ * The field's measured content height, from `onContentSizeChange`.
454
+ *
455
+ * Measured rather than counted: a draft wraps, so the number of LINES is not
456
+ * the number of newlines, and only the field knows how its own text laid out.
457
+ */
458
+ const [contentHeight, setContentHeight] = useState(LINE_HEIGHT);
414
459
  const [modelWidth, setModelWidth] = useState(0);
415
460
 
416
461
  // The chat's add surface is a step lighter than the Composer Panel's:
@@ -433,27 +478,72 @@ export function ComposerPillBase({
433
478
  );
434
479
 
435
480
  const submit = useCallback(() => {
436
- if (disabled) return;
481
+ if (disabled || busy) return;
437
482
  onSubmit?.(text);
438
- }, [disabled, onSubmit, text]);
483
+ }, [disabled, busy, onSubmit, text]);
439
484
 
440
485
  const onKeyPress = useCallback(
441
486
  (event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
442
- const native: TextInputKeyPressEventData & { isComposing?: boolean } = event.nativeEvent;
443
- if (!IS_WEB || native.key !== 'Enter' || native.isComposing || disabled) return;
487
+ const native: TextInputKeyPressEventData & { shiftKey?: boolean; isComposing?: boolean } = event.nativeEvent;
488
+ /*
489
+ * Shift+Enter is a NEWLINE, not a send.
490
+ *
491
+ * This handler used to read only the key, so a person holding shift to
492
+ * break a line sent the half-written message instead — on a field that
493
+ * could not have held the line anyway. `ComposerPanelBase` has always
494
+ * had the clause (`… || native.shiftKey || …`); the pill did not, and
495
+ * the two composers disagreeing about the most-used key in a composer
496
+ * was a difference nobody chose.
497
+ */
498
+ if (
499
+ !IS_WEB
500
+ || native.key !== 'Enter'
501
+ || native.shiftKey
502
+ || native.isComposing
503
+ || disabled
504
+ || busy
505
+ ) {
506
+ return;
507
+ }
444
508
  event.preventDefault();
445
509
  submit();
446
510
  },
447
- [submit, disabled],
511
+ [submit, disabled, busy],
448
512
  );
449
513
 
514
+ /*
515
+ * How tall the pill is: one line, or as many as the draft needs up to
516
+ * `maxLines`.
517
+ *
518
+ * It was `height: PILL_HEIGHT` with a `height: 20` field and no `multiline`
519
+ * — under react-native-web that is an `<input>`, which cannot hold a newline
520
+ * and cannot grow. A composer for an assistant has to hold a paragraph, so
521
+ * the field is multiline and the pill measures it.
522
+ *
523
+ * The radius stays at 9999 while it is one line and squares off to 26 beyond
524
+ * that: a fully-round multi-line box puts the first and last lines inside
525
+ * the curve, where the text meets the edge.
526
+ */
527
+ const lines = Math.min(maxLines, Math.max(1, Math.ceil(contentHeight / LINE_HEIGHT) || 1));
528
+ const fieldHeight = lines * LINE_HEIGHT;
529
+ /*
530
+ * At one line the pill is exactly what it always was. Its 52 comes from the
531
+ * CONTROLS — a 36px disc inside 8px padding — not from the 20px field, so
532
+ * growing from the field's height would have SHRUNK the resting pill to 36.
533
+ * Each line after the first adds its own height and nothing else moves.
534
+ */
535
+ const pillHeight = PILL_HEIGHT + (lines - 1) * LINE_HEIGHT;
536
+ const multiLine = lines > 1;
537
+
450
538
  const pillStyle: WebCssStyle = {
451
539
  width: '100%',
452
- height: PILL_HEIGHT,
540
+ height: pillHeight,
453
541
  flexDirection: 'row',
454
- alignItems: 'center',
542
+ // The controls sit with the LAST line once the field has grown, which is
543
+ // where the caret is; centred, they would drift to the middle of the draft.
544
+ alignItems: multiLine ? 'flex-end' : 'center',
455
545
  gap: 10,
456
- borderRadius: 9999,
546
+ borderRadius: multiLine ? 26 : 9999,
457
547
  padding: 8,
458
548
  backgroundColor: surface ? palette.surface : 'transparent',
459
549
  boxShadow: surface ? palette.shadowXs : undefined,
@@ -461,10 +551,12 @@ export function ComposerPillBase({
461
551
  };
462
552
 
463
553
  const fieldStyle: WebCssStyle = {
464
- height: 20,
554
+ height: fieldHeight,
465
555
  minWidth: 0,
466
556
  flex: 1,
467
- ...(IS_WEB && !focused
557
+ // The fade that hints at overflowing text is a ONE-LINE affordance: on a
558
+ // grown field it would dim the end of every line.
559
+ ...(IS_WEB && !focused && !multiLine
468
560
  ? {
469
561
  maskImage: 'linear-gradient(to right, #000 calc(100% - 32px), transparent 100%)',
470
562
  WebkitMaskImage: 'linear-gradient(to right, #000 calc(100% - 32px), transparent 100%)',
@@ -504,9 +596,14 @@ export function ComposerPillBase({
504
596
  selectionColor={palette.accent500}
505
597
  cursorColor={palette.accent500}
506
598
  returnKeyType="send"
599
+ multiline
600
+ onContentSizeChange={(event) => setContentHeight(event.nativeEvent.contentSize.height)}
507
601
  style={{
508
602
  width: '100%',
509
- height: 20,
603
+ height: fieldHeight,
604
+ // react-native-web gives a multiline field a resize grip and a
605
+ // scrollbar it does not need: the pill owns the height.
606
+ ...(IS_WEB ? { resize: 'none', overflowY: lines >= maxLines ? 'auto' : 'hidden' } : null),
510
607
  padding: 0,
511
608
  margin: 0,
512
609
  ...TYPE_SCALE['body-regular'],
@@ -518,12 +615,12 @@ export function ComposerPillBase({
518
615
  />
519
616
  </View>
520
617
 
521
- {models && models.length > 0 ? (
618
+ {lineup.length > 0 ? (
522
619
  <View style={{ position: 'relative', flexShrink: 0 }}>
523
620
  <GlassChip shown={glass && modelWidth > 0} radius={12} dark={theme.isDark} />
524
621
  <ModelMenu
525
- models={models}
526
- model={modelName}
622
+ models={lineup}
623
+ modelId={modelId}
527
624
  onModelChange={setModel}
528
625
  effort={effortValue}
529
626
  onEffortChange={setEffort}
@@ -547,7 +644,11 @@ export function ComposerPillBase({
547
644
  palette={controlPalette}
548
645
  />
549
646
  </View>
550
- <SendButton disabled={disabled} onPress={submit} label={labels.send} palette={palette} />
647
+ {busy && onStop ? (
648
+ <StopButton onPress={onStop} label={labels.stop} palette={palette} />
649
+ ) : (
650
+ <SendButton disabled={disabled} onPress={submit} label={labels.send} palette={palette} />
651
+ )}
551
652
  </View>
552
653
  </View>
553
654
  );
@@ -562,5 +663,16 @@ const DEFAULT_PANEL_LABELS = {
562
663
  learnMore: 'Learn more',
563
664
  voice: 'Voice input',
564
665
  send: 'Send message',
666
+ stop: 'Stop generating',
565
667
  remove: 'Remove',
566
668
  };
669
+
670
+ /**
671
+ * A bare string entry is its own id: the shorthand stays exact, and an `{ id,
672
+ * name }` entry keys, matches and reports by id like `ModelPicker` does.
673
+ */
674
+ function normalizeModels(
675
+ models: ReadonlyArray<string | ModelPickerModel>,
676
+ ): ReadonlyArray<ModelPickerModel> {
677
+ return models.map((entry) => (typeof entry === 'string' ? { id: entry, name: entry } : entry));
678
+ }
@@ -9,10 +9,13 @@ const TRACK_HEIGHT = 27;
9
9
  const THUMB_WIDTH = 21;
10
10
 
11
11
  interface EffortSliderProps {
12
- value: number;
12
+ /** The chosen stop, or `null` for no choice yet. */
13
+ value: number | null;
13
14
  onChange: (value: number) => void;
14
15
  levels: ReadonlyArray<string>;
15
16
  label: string;
17
+ /** What `aria-valuetext` reads with no stop chosen (the "Auto" label). */
18
+ unsetLabel: string;
16
19
  palette: ComposerPalette;
17
20
  testID?: string;
18
21
  }
@@ -25,6 +28,11 @@ interface EffortSliderProps {
25
28
  * past the value fade their tick to 30% over 300ms; the fill follows the thumb
26
29
  * over 150ms.
27
30
  *
31
+ * With `value` `null` nothing is committed: the fill is empty, every tick is
32
+ * faded and the thumb is hollow and dashed, so "no stop chosen" cannot be read
33
+ * as "the first stop". The first press or key commits a stop, and there is no
34
+ * way back — the caller owns the `null`.
35
+ *
28
36
  * Drag or press anywhere on the track (PanResponder on both platforms); on web
29
37
  * the thumb is focusable and answers the arrow keys, Home and End.
30
38
  *
@@ -32,20 +40,25 @@ interface EffortSliderProps {
32
40
  * starfield, with the ticks blown off the track) has no native counterpart,
33
41
  * so at Max the slider simply rests at its last stop.
34
42
  */
35
- export function EffortSlider({ value, onChange, levels, label, palette, testID }: EffortSliderProps) {
43
+ export function EffortSlider({ value, onChange, levels, label, unsetLabel, palette, testID }: EffortSliderProps) {
36
44
  const [width, setWidth] = useState(0);
37
45
  const max = Math.max(0, levels.length - 1);
38
- const clamped = Math.min(Math.max(value, 0), max);
39
- const fraction = max > 0 ? clamped / max : 0;
46
+ const chosen = value === null ? null : Math.min(Math.max(value, 0), max);
47
+ const fraction = chosen !== null && max > 0 ? chosen / max : 0;
40
48
  const rail = Math.max(0, width - THUMB_WIDTH);
41
49
 
42
- const stateRef = useRef({ value: clamped, rail, max, onChange });
43
- stateRef.current = { value: clamped, rail, max, onChange };
44
- const startRef = useRef(clamped);
50
+ const stateRef = useRef<{ value: number | null; rail: number; max: number; onChange: (next: number) => void }>({
51
+ value: chosen,
52
+ rail,
53
+ max,
54
+ onChange,
55
+ });
56
+ stateRef.current = { value: chosen, rail, max, onChange };
57
+ const startRef = useRef(chosen ?? 0);
45
58
 
46
59
  const valueAt = useCallback((x: number) => {
47
60
  const s = stateRef.current;
48
- if (s.rail <= 0 || s.max <= 0) return s.value;
61
+ if (s.rail <= 0 || s.max <= 0) return s.value ?? 0;
49
62
  const f = Math.min(Math.max((x - THUMB_WIDTH / 2) / s.rail, 0), 1);
50
63
  return Math.round(f * s.max);
51
64
  }, []);
@@ -83,11 +96,17 @@ export function EffortSlider({ value, onChange, levels, label, palette, testID }
83
96
  tabIndex: 0,
84
97
  onKeyDown: (event: { key: string; preventDefault: () => void }) => {
85
98
  const s = stateRef.current;
99
+ // With nothing chosen, either arrow commits the first stop.
100
+ const current = s.value;
86
101
  const next =
87
102
  event.key === 'ArrowRight' || event.key === 'ArrowUp'
88
- ? Math.min(s.max, s.value + 1)
103
+ ? current === null
104
+ ? 0
105
+ : Math.min(s.max, current + 1)
89
106
  : event.key === 'ArrowLeft' || event.key === 'ArrowDown'
90
- ? Math.max(0, s.value - 1)
107
+ ? current === null
108
+ ? 0
109
+ : Math.max(0, current - 1)
91
110
  : event.key === 'Home'
92
111
  ? 0
93
112
  : event.key === 'End'
@@ -108,9 +127,10 @@ export function EffortSlider({ value, onChange, levels, label, palette, testID }
108
127
  height: TRACK_HEIGHT,
109
128
  borderRadius: 7,
110
129
  borderWidth: 1,
130
+ borderStyle: chosen === null ? 'dashed' : 'solid',
111
131
  borderColor: palette.thumbBorder,
112
- backgroundColor: palette.surface,
113
- boxShadow: palette.shadowXs,
132
+ backgroundColor: chosen === null ? 'transparent' : palette.surface,
133
+ boxShadow: chosen === null ? undefined : palette.shadowXs,
114
134
  '--bloom-composer-ring': palette.focusRing,
115
135
  };
116
136
 
@@ -121,12 +141,12 @@ export function EffortSlider({ value, onChange, levels, label, palette, testID }
121
141
  accessibilityLabel={label}
122
142
  aria-valuemin={0}
123
143
  aria-valuemax={max}
124
- aria-valuenow={clamped}
125
- aria-valuetext={levels[clamped]}
144
+ aria-valuenow={chosen ?? undefined}
145
+ aria-valuetext={chosen === null ? unsetLabel : levels[chosen]}
126
146
  accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
127
147
  onAccessibilityAction={(event) => {
128
- if (event.nativeEvent.actionName === 'increment') commit(Math.min(max, clamped + 1));
129
- if (event.nativeEvent.actionName === 'decrement') commit(Math.max(0, clamped - 1));
148
+ if (event.nativeEvent.actionName === 'increment') commit(chosen === null ? 0 : Math.min(max, chosen + 1));
149
+ if (event.nativeEvent.actionName === 'decrement') commit(chosen === null ? 0 : Math.max(0, chosen - 1));
130
150
  }}
131
151
  style={{ width: '100%' }}>
132
152
  <View
@@ -149,7 +169,7 @@ export function EffortSlider({ value, onChange, levels, label, palette, testID }
149
169
  top: 0,
150
170
  bottom: 0,
151
171
  left: 0,
152
- width: fraction * rail + THUMB_WIDTH,
172
+ width: chosen === null ? 0 : fraction * rail + THUMB_WIDTH,
153
173
  borderRadius: 8,
154
174
  backgroundColor: palette.tertiaryHover,
155
175
  }}
@@ -175,7 +195,7 @@ export function EffortSlider({ value, onChange, levels, label, palette, testID }
175
195
  height: 13,
176
196
  borderRadius: 2,
177
197
  backgroundColor: palette.iconTertiary,
178
- opacity: index > clamped ? 0.3 : 1,
198
+ opacity: chosen === null || index > chosen ? 0.3 : 1,
179
199
  }}
180
200
  />
181
201
  ))}
@@ -57,6 +57,7 @@ const DEFAULT_LABELS: Required<ModelPickerLabels> = {
57
57
  noMatches: 'No models match',
58
58
  providers: 'Providers',
59
59
  effort: 'Effort',
60
+ effortAuto: 'Auto',
60
61
  faster: 'Faster',
61
62
  smarter: 'Smarter',
62
63
  };
@@ -218,6 +219,11 @@ function BlurInLevel({ level, palette }: { level: string; palette: ComposerPalet
218
219
  );
219
220
  }
220
221
 
222
+ /**
223
+ * The effort chip on the checked row. Drawn only where there are stops to
224
+ * choose from — a model with no effort axis gets no chip, rather than an empty
225
+ * one over a slider that cannot commit.
226
+ */
221
227
  function EffortMenu({
222
228
  value,
223
229
  onChange,
@@ -225,7 +231,7 @@ function EffortMenu({
225
231
  labels,
226
232
  palette,
227
233
  }: {
228
- value: number;
234
+ value: number | null;
229
235
  onChange: (next: number) => void;
230
236
  levels: ReadonlyArray<string>;
231
237
  labels: Required<ModelPickerLabels>;
@@ -235,7 +241,7 @@ function EffortMenu({
235
241
  const triggerRef = useRef<View>(null);
236
242
  const [open, setOpen] = useState(false);
237
243
  const [hovered, setHovered] = useState(false);
238
- const level = levels[value] ?? levels[DEFAULT_EFFORT] ?? '';
244
+ const level = value === null ? labels.effortAuto : (levels[value] ?? labels.effortAuto);
239
245
 
240
246
  const chipStyle: WebCssStyle = {
241
247
  flexShrink: 0,
@@ -314,7 +320,14 @@ function EffortMenu({
314
320
  </Text>
315
321
  </View>
316
322
  <View style={{ paddingLeft: 8, paddingRight: 8, paddingBottom: 8 }}>
317
- <EffortSlider value={value} onChange={onChange} levels={levels} label={labels.effort} palette={palette} />
323
+ <EffortSlider
324
+ value={value}
325
+ onChange={onChange}
326
+ levels={levels}
327
+ label={labels.effort}
328
+ unsetLabel={labels.effortAuto}
329
+ palette={palette}
330
+ />
318
331
  </View>
319
332
  </View>
320
333
  </View>
@@ -388,7 +401,7 @@ function ModelRow({
388
401
  {searching ? <InlineAside color={palette.textTertiary}>{provider.name}</InlineAside> : null}
389
402
  </Text>
390
403
  </View>
391
- {checked ? <View style={{ flexShrink: 0, flexDirection: 'row' }}>{effort}</View> : null}
404
+ {effort ? <View style={{ flexShrink: 0, flexDirection: 'row' }}>{effort}</View> : null}
392
405
  </View>
393
406
  <View pointerEvents="none">
394
407
  <RadioIndicator selected={checked} size={14} />
@@ -447,10 +460,13 @@ export function ModelPickerBase({
447
460
  defaultValue: defaultValue ?? providers[0]?.models[0]?.id ?? '',
448
461
  onChange: onValueChange,
449
462
  });
450
- const [effortValue, setEffort] = useControllableState<number>({
463
+ const [effortValue, setEffort] = useControllableState<number | null>({
451
464
  value: effort,
452
465
  defaultValue: defaultEffort,
453
- onChange: onEffortChange,
466
+ // The picker can only ever land on a stop; `null` is the caller's to hold.
467
+ onChange: (next) => {
468
+ if (next !== null) onEffortChange?.(next);
469
+ },
454
470
  });
455
471
 
456
472
  const selected: Match | null =
@@ -724,7 +740,7 @@ export function ModelPickerBase({
724
740
  }
725
741
  }}
726
742
  effort={
727
- checked ? (
743
+ checked && effortLevels.length > 0 ? (
728
744
  <EffortMenu
729
745
  value={effortValue}
730
746
  onChange={setEffort}