@oxy.so/bloom 3.3.0 → 3.5.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.
@@ -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',
@@ -155,6 +158,13 @@ function ModelRow({
155
158
  }) {
156
159
  const [hovered, setHovered] = useState(false);
157
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);
158
168
  return (
159
169
  <Pressable
160
170
  {...dataHook('bloomComposerRow')}
@@ -409,6 +419,9 @@ export function ComposerPillBase({
409
419
  inputRef,
410
420
  labels: labelOverrides,
411
421
  style,
422
+ maxLines = 8,
423
+ emptyAction,
424
+ onKeyPress: onKeyPressProp,
412
425
  testID,
413
426
  }: ComposerPillProps) {
414
427
  useComposerWebCss();
@@ -438,6 +451,13 @@ export function ComposerPillBase({
438
451
  },
439
452
  });
440
453
  const [focused, setFocused] = useState(false);
454
+ /**
455
+ * The field's measured content height, from `onContentSizeChange`.
456
+ *
457
+ * Measured rather than counted: a draft wraps, so the number of LINES is not
458
+ * the number of newlines, and only the field knows how its own text laid out.
459
+ */
460
+ const [contentHeight, setContentHeight] = useState(LINE_HEIGHT);
441
461
  const [modelWidth, setModelWidth] = useState(0);
442
462
 
443
463
  // The chat's add surface is a step lighter than the Composer Panel's:
@@ -466,21 +486,79 @@ export function ComposerPillBase({
466
486
 
467
487
  const onKeyPress = useCallback(
468
488
  (event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
469
- const native: TextInputKeyPressEventData & { isComposing?: boolean } = event.nativeEvent;
470
- if (!IS_WEB || native.key !== 'Enter' || native.isComposing || disabled || busy) return;
489
+ /*
490
+ * The host sees the key first, and may take it.
491
+ *
492
+ * A composer with a suggestion list over it needs the arrows, Enter and
493
+ * Escape to drive the list, not the field — and there was no way to ask
494
+ * for them, so a host could only offer a list its keyboard could not
495
+ * reach. It runs BEFORE the Enter rule below and a `defaultPrevented`
496
+ * event stops here, which is the only ordering that lets the list win
497
+ * Enter while the field keeps it the rest of the time.
498
+ */
499
+ onKeyPressProp?.(event);
500
+ if (event.defaultPrevented) return;
501
+
502
+ const native: TextInputKeyPressEventData & { shiftKey?: boolean; isComposing?: boolean } = event.nativeEvent;
503
+ /*
504
+ * Shift+Enter is a NEWLINE, not a send.
505
+ *
506
+ * This handler used to read only the key, so a person holding shift to
507
+ * break a line sent the half-written message instead — on a field that
508
+ * could not have held the line anyway. `ComposerPanelBase` has always
509
+ * had the clause (`… || native.shiftKey || …`); the pill did not, and
510
+ * the two composers disagreeing about the most-used key in a composer
511
+ * was a difference nobody chose.
512
+ */
513
+ if (
514
+ !IS_WEB
515
+ || native.key !== 'Enter'
516
+ || native.shiftKey
517
+ || native.isComposing
518
+ || disabled
519
+ || busy
520
+ ) {
521
+ return;
522
+ }
471
523
  event.preventDefault();
472
524
  submit();
473
525
  },
474
- [submit, disabled, busy],
526
+ [submit, disabled, busy, onKeyPressProp],
475
527
  );
476
528
 
529
+ /*
530
+ * How tall the pill is: one line, or as many as the draft needs up to
531
+ * `maxLines`.
532
+ *
533
+ * It was `height: PILL_HEIGHT` with a `height: 20` field and no `multiline`
534
+ * — under react-native-web that is an `<input>`, which cannot hold a newline
535
+ * and cannot grow. A composer for an assistant has to hold a paragraph, so
536
+ * the field is multiline and the pill measures it.
537
+ *
538
+ * The radius stays at 9999 while it is one line and squares off to 26 beyond
539
+ * that: a fully-round multi-line box puts the first and last lines inside
540
+ * the curve, where the text meets the edge.
541
+ */
542
+ const lines = Math.min(maxLines, Math.max(1, Math.ceil(contentHeight / LINE_HEIGHT) || 1));
543
+ const fieldHeight = lines * LINE_HEIGHT;
544
+ /*
545
+ * At one line the pill is exactly what it always was. Its 52 comes from the
546
+ * CONTROLS — a 36px disc inside 8px padding — not from the 20px field, so
547
+ * growing from the field's height would have SHRUNK the resting pill to 36.
548
+ * Each line after the first adds its own height and nothing else moves.
549
+ */
550
+ const pillHeight = PILL_HEIGHT + (lines - 1) * LINE_HEIGHT;
551
+ const multiLine = lines > 1;
552
+
477
553
  const pillStyle: WebCssStyle = {
478
554
  width: '100%',
479
- height: PILL_HEIGHT,
555
+ height: pillHeight,
480
556
  flexDirection: 'row',
481
- alignItems: 'center',
557
+ // The controls sit with the LAST line once the field has grown, which is
558
+ // where the caret is; centred, they would drift to the middle of the draft.
559
+ alignItems: multiLine ? 'flex-end' : 'center',
482
560
  gap: 10,
483
- borderRadius: 9999,
561
+ borderRadius: multiLine ? 26 : 9999,
484
562
  padding: 8,
485
563
  backgroundColor: surface ? palette.surface : 'transparent',
486
564
  boxShadow: surface ? palette.shadowXs : undefined,
@@ -488,10 +566,12 @@ export function ComposerPillBase({
488
566
  };
489
567
 
490
568
  const fieldStyle: WebCssStyle = {
491
- height: 20,
569
+ height: fieldHeight,
492
570
  minWidth: 0,
493
571
  flex: 1,
494
- ...(IS_WEB && !focused
572
+ // The fade that hints at overflowing text is a ONE-LINE affordance: on a
573
+ // grown field it would dim the end of every line.
574
+ ...(IS_WEB && !focused && !multiLine
495
575
  ? {
496
576
  maskImage: 'linear-gradient(to right, #000 calc(100% - 32px), transparent 100%)',
497
577
  WebkitMaskImage: 'linear-gradient(to right, #000 calc(100% - 32px), transparent 100%)',
@@ -531,9 +611,14 @@ export function ComposerPillBase({
531
611
  selectionColor={palette.accent500}
532
612
  cursorColor={palette.accent500}
533
613
  returnKeyType="send"
614
+ multiline
615
+ onContentSizeChange={(event) => setContentHeight(event.nativeEvent.contentSize.height)}
534
616
  style={{
535
617
  width: '100%',
536
- height: 20,
618
+ height: fieldHeight,
619
+ // react-native-web gives a multiline field a resize grip and a
620
+ // scrollbar it does not need: the pill owns the height.
621
+ ...(IS_WEB ? { resize: 'none', overflowY: lines >= maxLines ? 'auto' : 'hidden' } : null),
537
622
  padding: 0,
538
623
  margin: 0,
539
624
  ...TYPE_SCALE['body-regular'],
@@ -574,8 +659,21 @@ export function ComposerPillBase({
574
659
  palette={controlPalette}
575
660
  />
576
661
  </View>
662
+ {/* What the trailing control is, in the order the states rank.
663
+ *
664
+ * Stop wins: a turn in flight is the one thing a person needs to be
665
+ * able to reach, and `StopButton` deliberately takes no `disabled`.
666
+ *
667
+ * Then `emptyAction`, if the host gave one and there is nothing to
668
+ * send. An assistant with a voice mode puts it here — the slot where
669
+ * send WOULD be, which is where the thumb already is — and it is the
670
+ * host's control, not a Bloom affordance, because only the host knows
671
+ * whether it has one. Without it the send button simply sits there
672
+ * disabled, as it always did. */}
577
673
  {busy && onStop ? (
578
674
  <StopButton onPress={onStop} label={labels.stop} palette={palette} />
675
+ ) : emptyAction !== undefined && text.trim() === '' ? (
676
+ emptyAction
579
677
  ) : (
580
678
  <SendButton disabled={disabled} onPress={submit} label={labels.send} palette={palette} />
581
679
  )}
@@ -1,5 +1,11 @@
1
1
  import type { ComponentType, ReactNode, RefObject } from 'react';
2
- import type { StyleProp, TextInput, ViewStyle } from 'react-native';
2
+ import type {
3
+ NativeSyntheticEvent,
4
+ StyleProp,
5
+ TextInput,
6
+ TextInputKeyPressEventData,
7
+ ViewStyle,
8
+ } from 'react-native';
3
9
 
4
10
  import type { Props as IconProps } from '../icons/shared';
5
11
 
@@ -33,6 +39,15 @@ export interface ComposerPanelAddMenuRow {
33
39
  iconSize?: 20 | 24;
34
40
  /** Your own 24px artwork, drawn instead of `icon`. */
35
41
  image?: ReactNode;
42
+ /**
43
+ * Draw the row as a SWITCH, ticked or not, instead of an action.
44
+ *
45
+ * Omit it and the row is an action, as every row was before. A host whose
46
+ * menu holds toggles — web search on, deep research off — could otherwise
47
+ * only offer rows that changed something and then looked identical either
48
+ * way.
49
+ */
50
+ checked?: boolean;
36
51
  }
37
52
 
38
53
  export interface ComposerPanelAddMenuGroup {
@@ -395,6 +410,38 @@ export interface ComposerPillProps {
395
410
  */
396
411
  glass?: boolean;
397
412
 
413
+ /**
414
+ * How many lines the pill grows to before the field scrolls instead. Default
415
+ * `8`.
416
+ *
417
+ * The pill is one line at rest and measures its own content as the draft
418
+ * wraps — a composer for an assistant has to hold a paragraph. `1` keeps the
419
+ * fixed single-line box this had before it could grow.
420
+ */
421
+ maxLines?: number;
422
+
423
+ /**
424
+ * Drawn where send would be, while the draft is empty and no turn is in
425
+ * flight.
426
+ *
427
+ * An assistant with a voice mode puts its call button here — the slot the
428
+ * thumb is already over. Omit it and send simply sits there disabled, as
429
+ * before. A stop always wins over it: a turn in flight is the one thing a
430
+ * person has to be able to reach.
431
+ */
432
+ emptyAction?: ReactNode;
433
+
434
+ /**
435
+ * The field's key events, BEFORE the composer's own Enter rule, so a host
436
+ * can take a key from it.
437
+ *
438
+ * Calling `preventDefault()` stops the composer acting on that key. That is
439
+ * what a suggestion list over the composer needs: the arrows, Enter and
440
+ * Escape have to drive the list rather than the field, and without this a
441
+ * host could only offer a list its keyboard could not reach.
442
+ */
443
+ onKeyPress?: (event: NativeSyntheticEvent<TextInputKeyPressEventData>) => void;
444
+
398
445
  /** The field itself, for focus or a scripted demo. */
399
446
  inputRef?: RefObject<TextInput | null>;
400
447
  labels?: ComposerPillLabels;