@oxy.so/bloom 4.19.1 → 4.21.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 (179) hide show
  1. package/docs/adoption-matrix.mdx +6 -6
  2. package/docs/ai-chat.mdx +32 -1
  3. package/docs/button.mdx +1 -1
  4. package/docs/composer-panel.mdx +4 -0
  5. package/docs/design-tokens.mdx +22 -0
  6. package/docs/dialog.mdx +5 -0
  7. package/lib/commonjs/accordion/Accordion.js +7 -3
  8. package/lib/commonjs/accordion/Accordion.js.map +1 -1
  9. package/lib/commonjs/ai-chat/AiChat.js +2 -1
  10. package/lib/commonjs/ai-chat/AiChat.js.map +1 -1
  11. package/lib/commonjs/ai-chat/AiChat.web.js +2 -1
  12. package/lib/commonjs/ai-chat/AiChat.web.js.map +1 -1
  13. package/lib/commonjs/ai-chat/AiChatControls.js +60 -4
  14. package/lib/commonjs/ai-chat/AiChatControls.js.map +1 -1
  15. package/lib/commonjs/ai-chat/AiChatFeedbackRowBase.js +40 -12
  16. package/lib/commonjs/ai-chat/AiChatFeedbackRowBase.js.map +1 -1
  17. package/lib/commonjs/ai-chat/AiChatMessages.js +31 -5
  18. package/lib/commonjs/ai-chat/AiChatMessages.js.map +1 -1
  19. package/lib/commonjs/ai-chat/bindAiChat.js +10 -0
  20. package/lib/commonjs/ai-chat/bindAiChat.js.map +1 -1
  21. package/lib/commonjs/ai-chat/index.js +1 -1
  22. package/lib/commonjs/ai-chat/index.js.map +1 -1
  23. package/lib/commonjs/ai-chat/index.web.js +1 -1
  24. package/lib/commonjs/ai-chat/index.web.js.map +1 -1
  25. package/lib/commonjs/ai-chat/shared.js +11 -2
  26. package/lib/commonjs/ai-chat/shared.js.map +1 -1
  27. package/lib/commonjs/button/Button.js +37 -1
  28. package/lib/commonjs/button/Button.js.map +1 -1
  29. package/lib/commonjs/composer-panel/AttachmentStrip.js +108 -8
  30. package/lib/commonjs/composer-panel/AttachmentStrip.js.map +1 -1
  31. package/lib/commonjs/composer-panel/ComposerPanelBase.js +6 -2
  32. package/lib/commonjs/composer-panel/ComposerPanelBase.js.map +1 -1
  33. package/lib/commonjs/composer-panel/ComposerPillBase.js +2 -1
  34. package/lib/commonjs/composer-panel/ComposerPillBase.js.map +1 -1
  35. package/lib/commonjs/composer-panel/shared.js +2 -0
  36. package/lib/commonjs/composer-panel/shared.js.map +1 -1
  37. package/lib/commonjs/composer-panel/use-attachment-queue.js +5 -2
  38. package/lib/commonjs/composer-panel/use-attachment-queue.js.map +1 -1
  39. package/lib/commonjs/design-tokens/theme-css.js +24 -2
  40. package/lib/commonjs/design-tokens/theme-css.js.map +1 -1
  41. package/lib/commonjs/design-tokens/theme.css +8 -8
  42. package/lib/commonjs/dialog/DialogBottomSheet.js +19 -3
  43. package/lib/commonjs/dialog/DialogBottomSheet.js.map +1 -1
  44. package/lib/commonjs/dialog/DialogHeader.js +78 -6
  45. package/lib/commonjs/dialog/DialogHeader.js.map +1 -1
  46. package/lib/module/accordion/Accordion.js +7 -3
  47. package/lib/module/accordion/Accordion.js.map +1 -1
  48. package/lib/module/ai-chat/AiChat.js +1 -0
  49. package/lib/module/ai-chat/AiChat.js.map +1 -1
  50. package/lib/module/ai-chat/AiChat.web.js +1 -0
  51. package/lib/module/ai-chat/AiChat.web.js.map +1 -1
  52. package/lib/module/ai-chat/AiChatControls.js +58 -4
  53. package/lib/module/ai-chat/AiChatControls.js.map +1 -1
  54. package/lib/module/ai-chat/AiChatFeedbackRowBase.js +41 -13
  55. package/lib/module/ai-chat/AiChatFeedbackRowBase.js.map +1 -1
  56. package/lib/module/ai-chat/AiChatMessages.js +30 -4
  57. package/lib/module/ai-chat/AiChatMessages.js.map +1 -1
  58. package/lib/module/ai-chat/bindAiChat.js +11 -1
  59. package/lib/module/ai-chat/bindAiChat.js.map +1 -1
  60. package/lib/module/ai-chat/index.js +2 -2
  61. package/lib/module/ai-chat/index.js.map +1 -1
  62. package/lib/module/ai-chat/index.web.js +2 -2
  63. package/lib/module/ai-chat/index.web.js.map +1 -1
  64. package/lib/module/ai-chat/shared.js +11 -2
  65. package/lib/module/ai-chat/shared.js.map +1 -1
  66. package/lib/module/button/Button.js +38 -2
  67. package/lib/module/button/Button.js.map +1 -1
  68. package/lib/module/composer-panel/AttachmentStrip.js +108 -8
  69. package/lib/module/composer-panel/AttachmentStrip.js.map +1 -1
  70. package/lib/module/composer-panel/ComposerPanelBase.js +6 -2
  71. package/lib/module/composer-panel/ComposerPanelBase.js.map +1 -1
  72. package/lib/module/composer-panel/ComposerPillBase.js +2 -1
  73. package/lib/module/composer-panel/ComposerPillBase.js.map +1 -1
  74. package/lib/module/composer-panel/shared.js +2 -0
  75. package/lib/module/composer-panel/shared.js.map +1 -1
  76. package/lib/module/composer-panel/use-attachment-queue.js +5 -2
  77. package/lib/module/composer-panel/use-attachment-queue.js.map +1 -1
  78. package/lib/module/design-tokens/theme-css.js +23 -2
  79. package/lib/module/design-tokens/theme-css.js.map +1 -1
  80. package/lib/module/design-tokens/theme.css +8 -8
  81. package/lib/module/dialog/DialogBottomSheet.js +19 -3
  82. package/lib/module/dialog/DialogBottomSheet.js.map +1 -1
  83. package/lib/module/dialog/DialogHeader.js +76 -5
  84. package/lib/module/dialog/DialogHeader.js.map +1 -1
  85. package/lib/typescript/commonjs/accordion/Accordion.d.ts.map +1 -1
  86. package/lib/typescript/commonjs/ai-chat/AiChat.d.ts +4 -0
  87. package/lib/typescript/commonjs/ai-chat/AiChat.d.ts.map +1 -1
  88. package/lib/typescript/commonjs/ai-chat/AiChat.web.d.ts +4 -0
  89. package/lib/typescript/commonjs/ai-chat/AiChat.web.d.ts.map +1 -1
  90. package/lib/typescript/commonjs/ai-chat/AiChatControls.d.ts +22 -1
  91. package/lib/typescript/commonjs/ai-chat/AiChatControls.d.ts.map +1 -1
  92. package/lib/typescript/commonjs/ai-chat/AiChatFeedbackRowBase.d.ts +5 -2
  93. package/lib/typescript/commonjs/ai-chat/AiChatFeedbackRowBase.d.ts.map +1 -1
  94. package/lib/typescript/commonjs/ai-chat/AiChatMessages.d.ts +1 -1
  95. package/lib/typescript/commonjs/ai-chat/AiChatMessages.d.ts.map +1 -1
  96. package/lib/typescript/commonjs/ai-chat/bindAiChat.d.ts +5 -1
  97. package/lib/typescript/commonjs/ai-chat/bindAiChat.d.ts.map +1 -1
  98. package/lib/typescript/commonjs/ai-chat/index.d.ts +3 -3
  99. package/lib/typescript/commonjs/ai-chat/index.d.ts.map +1 -1
  100. package/lib/typescript/commonjs/ai-chat/index.web.d.ts +3 -3
  101. package/lib/typescript/commonjs/ai-chat/index.web.d.ts.map +1 -1
  102. package/lib/typescript/commonjs/ai-chat/shared.d.ts +2 -2
  103. package/lib/typescript/commonjs/ai-chat/shared.d.ts.map +1 -1
  104. package/lib/typescript/commonjs/ai-chat/types.d.ts +43 -5
  105. package/lib/typescript/commonjs/ai-chat/types.d.ts.map +1 -1
  106. package/lib/typescript/commonjs/button/Button.d.ts.map +1 -1
  107. package/lib/typescript/commonjs/composer-panel/AttachmentStrip.d.ts +10 -1
  108. package/lib/typescript/commonjs/composer-panel/AttachmentStrip.d.ts.map +1 -1
  109. package/lib/typescript/commonjs/composer-panel/ComposerPanelBase.d.ts +1 -1
  110. package/lib/typescript/commonjs/composer-panel/ComposerPanelBase.d.ts.map +1 -1
  111. package/lib/typescript/commonjs/composer-panel/shared.d.ts +3 -0
  112. package/lib/typescript/commonjs/composer-panel/shared.d.ts.map +1 -1
  113. package/lib/typescript/commonjs/composer-panel/types.d.ts +11 -0
  114. package/lib/typescript/commonjs/composer-panel/types.d.ts.map +1 -1
  115. package/lib/typescript/commonjs/composer-panel/use-attachment-queue.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/design-tokens/theme-css.d.ts +9 -1
  117. package/lib/typescript/commonjs/design-tokens/theme-css.d.ts.map +1 -1
  118. package/lib/typescript/commonjs/dialog/DialogBottomSheet.d.ts.map +1 -1
  119. package/lib/typescript/commonjs/dialog/DialogHeader.d.ts +24 -0
  120. package/lib/typescript/commonjs/dialog/DialogHeader.d.ts.map +1 -1
  121. package/lib/typescript/module/accordion/Accordion.d.ts.map +1 -1
  122. package/lib/typescript/module/ai-chat/AiChat.d.ts +4 -0
  123. package/lib/typescript/module/ai-chat/AiChat.d.ts.map +1 -1
  124. package/lib/typescript/module/ai-chat/AiChat.web.d.ts +4 -0
  125. package/lib/typescript/module/ai-chat/AiChat.web.d.ts.map +1 -1
  126. package/lib/typescript/module/ai-chat/AiChatControls.d.ts +22 -1
  127. package/lib/typescript/module/ai-chat/AiChatControls.d.ts.map +1 -1
  128. package/lib/typescript/module/ai-chat/AiChatFeedbackRowBase.d.ts +5 -2
  129. package/lib/typescript/module/ai-chat/AiChatFeedbackRowBase.d.ts.map +1 -1
  130. package/lib/typescript/module/ai-chat/AiChatMessages.d.ts +1 -1
  131. package/lib/typescript/module/ai-chat/AiChatMessages.d.ts.map +1 -1
  132. package/lib/typescript/module/ai-chat/bindAiChat.d.ts +5 -1
  133. package/lib/typescript/module/ai-chat/bindAiChat.d.ts.map +1 -1
  134. package/lib/typescript/module/ai-chat/index.d.ts +3 -3
  135. package/lib/typescript/module/ai-chat/index.d.ts.map +1 -1
  136. package/lib/typescript/module/ai-chat/index.web.d.ts +3 -3
  137. package/lib/typescript/module/ai-chat/index.web.d.ts.map +1 -1
  138. package/lib/typescript/module/ai-chat/shared.d.ts +2 -2
  139. package/lib/typescript/module/ai-chat/shared.d.ts.map +1 -1
  140. package/lib/typescript/module/ai-chat/types.d.ts +43 -5
  141. package/lib/typescript/module/ai-chat/types.d.ts.map +1 -1
  142. package/lib/typescript/module/button/Button.d.ts.map +1 -1
  143. package/lib/typescript/module/composer-panel/AttachmentStrip.d.ts +10 -1
  144. package/lib/typescript/module/composer-panel/AttachmentStrip.d.ts.map +1 -1
  145. package/lib/typescript/module/composer-panel/ComposerPanelBase.d.ts +1 -1
  146. package/lib/typescript/module/composer-panel/ComposerPanelBase.d.ts.map +1 -1
  147. package/lib/typescript/module/composer-panel/shared.d.ts +3 -0
  148. package/lib/typescript/module/composer-panel/shared.d.ts.map +1 -1
  149. package/lib/typescript/module/composer-panel/types.d.ts +11 -0
  150. package/lib/typescript/module/composer-panel/types.d.ts.map +1 -1
  151. package/lib/typescript/module/composer-panel/use-attachment-queue.d.ts.map +1 -1
  152. package/lib/typescript/module/design-tokens/theme-css.d.ts +9 -1
  153. package/lib/typescript/module/design-tokens/theme-css.d.ts.map +1 -1
  154. package/lib/typescript/module/dialog/DialogBottomSheet.d.ts.map +1 -1
  155. package/lib/typescript/module/dialog/DialogHeader.d.ts +24 -0
  156. package/lib/typescript/module/dialog/DialogHeader.d.ts.map +1 -1
  157. package/package.json +5 -2
  158. package/src/accordion/Accordion.tsx +9 -1
  159. package/src/ai-chat/AiChat.tsx +1 -0
  160. package/src/ai-chat/AiChat.web.tsx +1 -0
  161. package/src/ai-chat/AiChatControls.tsx +58 -3
  162. package/src/ai-chat/AiChatFeedbackRowBase.tsx +45 -17
  163. package/src/ai-chat/AiChatMessages.tsx +24 -1
  164. package/src/ai-chat/bindAiChat.tsx +19 -2
  165. package/src/ai-chat/index.ts +3 -1
  166. package/src/ai-chat/index.web.ts +3 -1
  167. package/src/ai-chat/shared.ts +11 -2
  168. package/src/ai-chat/types.ts +45 -5
  169. package/src/button/Button.tsx +41 -3
  170. package/src/composer-panel/AttachmentStrip.tsx +98 -6
  171. package/src/composer-panel/ComposerPanelBase.tsx +4 -0
  172. package/src/composer-panel/ComposerPillBase.tsx +1 -0
  173. package/src/composer-panel/shared.ts +5 -0
  174. package/src/composer-panel/types.ts +11 -0
  175. package/src/composer-panel/use-attachment-queue.ts +5 -2
  176. package/src/design-tokens/theme-css.ts +23 -2
  177. package/src/design-tokens/theme.css +8 -8
  178. package/src/dialog/DialogBottomSheet.tsx +21 -0
  179. package/src/dialog/DialogHeader.tsx +94 -11
@@ -9,9 +9,11 @@ import Animated, {
9
9
  } from 'react-native-reanimated';
10
10
 
11
11
  import type { Props as IconProps } from '../icons/shared';
12
+ import { DISABLED_OPACITY } from '../styles/tokens';
12
13
  import type { WebCssStyle } from '../styles/web-view-style';
13
14
  import { useAiChatPlatform } from './context';
14
15
  import { dataHook, IS_WEB, type AiChatPalette } from './shared';
16
+ import type { AiChatTurnAction } from './types';
15
17
 
16
18
  /**
17
19
  * The family's small interactive pieces, shared by the message, image, code
@@ -178,6 +180,8 @@ export function SurfaceAction({
178
180
  hoverBackground,
179
181
  palette,
180
182
  glyph,
183
+ active,
184
+ disabled = false,
181
185
  style,
182
186
  testID,
183
187
  }: {
@@ -194,11 +198,20 @@ export function SurfaceAction({
194
198
  hoverBackground: string;
195
199
  palette: AiChatPalette;
196
200
  glyph: (color: string) => React.ReactNode;
201
+ /**
202
+ * A toggle's state: `true` holds the hover surface and the primary glyph.
203
+ * Undefined for a plain button, which then carries no pressed state at all.
204
+ */
205
+ active?: boolean;
206
+ disabled?: boolean;
197
207
  style?: StyleProp<ViewStyle>;
198
208
  testID?: string;
199
209
  }) {
210
+ const isToggle = active !== undefined;
200
211
  const tip = useHoverTooltip({ open: tooltipOpen, onOpenChange: onTooltipOpenChange });
201
- const [hovered, setHovered] = useState(false);
212
+ const [hoveredRaw, setHovered] = useState(false);
213
+ const hovered = hoveredRaw && !disabled;
214
+ const lit = hovered || active === true;
202
215
  // A press focuses the control too; only keyboard focus should raise the tooltip.
203
216
  const pointerDown = useRef(false);
204
217
  const surface: WebCssStyle = {
@@ -207,7 +220,8 @@ export function SurfaceAction({
207
220
  alignItems: 'center',
208
221
  padding,
209
222
  borderRadius: radius,
210
- backgroundColor: hovered ? hoverBackground : background,
223
+ backgroundColor: lit ? hoverBackground : background,
224
+ opacity: disabled ? DISABLED_OPACITY : 1,
211
225
  '--bloom-ai-chat-ring': palette.ring,
212
226
  };
213
227
  return (
@@ -217,6 +231,12 @@ export function SurfaceAction({
217
231
  testID={testID}
218
232
  accessibilityRole="button"
219
233
  accessibilityLabel={label}
234
+ // BOTH spellings of the toggle state: react-native-web drops
235
+ // `accessibilityState` and React Native has no `aria-pressed`.
236
+ {...(isToggle ? { 'aria-pressed': active } : null)}
237
+ accessibilityState={{ disabled, ...(isToggle ? { selected: active } : null) }}
238
+ aria-disabled={disabled || undefined}
239
+ disabled={disabled}
220
240
  onPressIn={() => {
221
241
  pointerDown.current = true;
222
242
  tip.onHoverOut();
@@ -238,8 +258,43 @@ export function SurfaceAction({
238
258
  }}
239
259
  onBlur={() => tip.setOpen(false)}
240
260
  style={[surface, style]}>
241
- {glyph(hovered ? palette.iconPrimary : palette.iconSecondary)}
261
+ {glyph(lit ? palette.iconPrimary : palette.iconSecondary)}
242
262
  </Pressable>
243
263
  </WithTooltip>
244
264
  );
245
265
  }
266
+
267
+ /** The feedback row's button surface: 28 square, radius 8, p 6, tertiary → secondary-hover. */
268
+ export function turnActionSurface(palette: AiChatPalette) {
269
+ return {
270
+ padding: 6,
271
+ radius: 8,
272
+ background: palette.tertiary,
273
+ hoverBackground: palette.secondaryHover,
274
+ palette,
275
+ };
276
+ }
277
+
278
+ /** One caller-supplied turn action, drawn as a feedback-row button. */
279
+ export function TurnActionButton({
280
+ action,
281
+ palette,
282
+ testID,
283
+ }: {
284
+ action: AiChatTurnAction;
285
+ palette: AiChatPalette;
286
+ testID?: string;
287
+ }) {
288
+ const Icon = action.icon;
289
+ return (
290
+ <SurfaceAction
291
+ {...turnActionSurface(palette)}
292
+ testID={testID ? `${testID}-${action.key}` : undefined}
293
+ label={action.label}
294
+ onPress={action.onPress}
295
+ active={action.active}
296
+ disabled={action.disabled}
297
+ glyph={(color) => <Icon width={16} height={16} fill={color} />}
298
+ />
299
+ );
300
+ }
@@ -5,9 +5,9 @@ import { RiCheckLine } from '../icons/remix/RiCheckLine';
5
5
  import { RiFileCopyLine } from '../icons/remix/RiFileCopyLine';
6
6
  import { RiThumbDownLine } from '../icons/remix/RiThumbDownLine';
7
7
  import { RiThumbUpLine } from '../icons/remix/RiThumbUpLine';
8
- import { SurfaceAction, SwapGlyph } from './AiChatControls';
8
+ import { SurfaceAction, SwapGlyph, TurnActionButton, turnActionSurface } from './AiChatControls';
9
9
  import { CONFIRM_MS, useAiChatPalette, useAiChatWebCss } from './shared';
10
- import type { AiChatFeedbackRowProps } from './types';
10
+ import type { AiChatCopyResult, AiChatFeedbackRowProps } from './types';
11
11
 
12
12
  const DEFAULT_LABELS = {
13
13
  like: 'Good response',
@@ -26,9 +26,12 @@ const DEFAULT_LABELS = {
26
26
  * tooltip a `sm` tooltip above, after a 200ms hover
27
27
  * copy the copy glyph blurs, scales to 75% and fades out as a check comes
28
28
  * in (200ms ease-out); the tooltip is held open reading "Copied!"
29
- * for 1.6s, then dismissed even if the pointer is still over it
29
+ * for 1.6s, then dismissed even if the pointer is still over it — once
30
+ * the copy HAPPENED: an async handler is waited for, and a `false`,
31
+ * a throw or a rejection shows nothing
32
+ * actions the caller's extra buttons after copy, on the same surface
30
33
  */
31
- export function AiChatFeedbackRowBase({ onLike, onDislike, onCopy, labels, style, testID }: AiChatFeedbackRowProps) {
34
+ export function AiChatFeedbackRowBase({ onLike, onDislike, onCopy, actions, labels, style, testID }: AiChatFeedbackRowProps) {
32
35
  useAiChatWebCss();
33
36
  const palette = useAiChatPalette();
34
37
  const l = useMemo(() => ({ ...DEFAULT_LABELS, ...labels }), [labels]);
@@ -36,16 +39,20 @@ export function AiChatFeedbackRowBase({ onLike, onDislike, onCopy, labels, style
36
39
  const [copyTooltipOpen, setCopyTooltipOpen] = useState(false);
37
40
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
38
41
 
39
- useEffect(
40
- () => () => {
42
+ // The latest press: a slower, earlier copy settling late must not confirm.
43
+ const attempt = useRef(0);
44
+ const mounted = useRef(true);
45
+
46
+ useEffect(() => {
47
+ mounted.current = true;
48
+ return () => {
49
+ mounted.current = false;
41
50
  if (timer.current) clearTimeout(timer.current);
42
- },
43
- [],
44
- );
51
+ };
52
+ }, []);
45
53
 
46
- const copy = () => {
54
+ const confirm = () => {
47
55
  if (timer.current) clearTimeout(timer.current);
48
- onCopy?.();
49
56
  setCopied(true);
50
57
  setCopyTooltipOpen(true);
51
58
  timer.current = setTimeout(() => {
@@ -54,14 +61,28 @@ export function AiChatFeedbackRowBase({ onLike, onDislike, onCopy, labels, style
54
61
  }, CONFIRM_MS);
55
62
  };
56
63
 
57
- const surface = {
58
- padding: 6,
59
- radius: 8,
60
- background: palette.tertiary,
61
- hoverBackground: palette.secondaryHover,
62
- palette,
64
+ const copy = () => {
65
+ const id = ++attempt.current;
66
+ let result: AiChatCopyResult;
67
+ try {
68
+ result = onCopy?.();
69
+ } catch {
70
+ return;
71
+ }
72
+ if (isPromiseLike(result)) {
73
+ result.then(
74
+ (ok) => {
75
+ if (ok !== false && mounted.current && id === attempt.current) confirm();
76
+ },
77
+ () => undefined,
78
+ );
79
+ return;
80
+ }
81
+ if (result !== false) confirm();
63
82
  };
64
83
 
84
+ const surface = turnActionSurface(palette);
85
+
65
86
  return (
66
87
  <View testID={testID} style={[{ flexDirection: 'row', alignItems: 'center', gap: 6 }, style]}>
67
88
  <SurfaceAction
@@ -97,6 +118,13 @@ export function AiChatFeedbackRowBase({ onLike, onDislike, onCopy, labels, style
97
118
  </View>
98
119
  )}
99
120
  />
121
+ {actions?.map((action) => (
122
+ <TurnActionButton key={action.key} action={action} palette={palette} testID={testID} />
123
+ ))}
100
124
  </View>
101
125
  );
102
126
  }
127
+
128
+ function isPromiseLike(value: unknown): value is PromiseLike<void | boolean> {
129
+ return typeof (value as PromiseLike<unknown> | null | undefined)?.then === 'function';
130
+ }
@@ -3,6 +3,7 @@ import { View, type LayoutChangeEvent } from 'react-native';
3
3
 
4
4
  import { RiLinkM } from '../icons/remix/RiLinkM';
5
5
  import { Text } from '../typography';
6
+ import { TurnActionButton } from './AiChatControls';
6
7
  import { AiChatFeedbackRowBase } from './AiChatFeedbackRowBase';
7
8
  import { RevealFade, RevealLine, RevealSequence, useRevealSlot } from './AiChatReveal';
8
9
  import { CARD_RADIUS, dataHook, IS_WEB, useAiChatPalette, useAiChatWebCss } from './shared';
@@ -23,6 +24,10 @@ import type {
23
24
  * user pushed right with a 6px bleed past the column (`-mr-1.5`), hugging
24
25
  * its text up to half the column + 6; radius 16, background-primary,
25
26
  * px 12 / py 11, shadow-card; body-regular text-primary, left-aligned
27
+ * actions under the user card, 8 below it and right-aligned: the feedback
28
+ * row's buttons, 6 apart. On a web pointer they fade in (150ms)
29
+ * while the turn is hovered or holds focus — opacity only, so they
30
+ * stay tabbable and announced; always shown on native and touch
26
31
  * assistant full width, gap 8, body-regular text-primary; the feedback row last
27
32
  * line a paragraph; `secondary` tone for status lines
28
33
  * bullets disc markers outside a 21px indent, items 8 apart
@@ -55,12 +60,13 @@ function asBlocks(children: React.ReactNode): React.ReactNode {
55
60
  });
56
61
  }
57
62
 
58
- export function AiChatUserMessage({ children, animate = true, style, testID }: AiChatUserMessageProps) {
63
+ export function AiChatUserMessageBase({ children, actions, animate = true, style, testID }: AiChatUserMessageProps) {
59
64
  useAiChatWebCss();
60
65
  const palette = useAiChatPalette();
61
66
  const [column, setColumn] = useState(0);
62
67
  return (
63
68
  <View
69
+ {...dataHook('bloomAiChatTurn')}
64
70
  testID={testID}
65
71
  onLayout={(event: LayoutChangeEvent) => setColumn(event.nativeEvent.layout.width)}
66
72
  style={[{ width: '100%', alignItems: 'flex-end' }, style]}>
@@ -80,6 +86,23 @@ export function AiChatUserMessage({ children, animate = true, style, testID }: A
80
86
  }}>
81
87
  <RevealSequence animate={animate}>{asBlocks(children)}</RevealSequence>
82
88
  </RevealFade>
89
+ {actions && actions.length > 0 ? (
90
+ <RevealFade animate={animate} style={{ marginTop: 8 }}>
91
+ <View
92
+ {...dataHook('bloomAiChatTurnActions')}
93
+ testID={testID ? `${testID}-actions` : undefined}
94
+ style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
95
+ {actions.map((action) => (
96
+ <TurnActionButton
97
+ key={action.key}
98
+ action={action}
99
+ palette={palette}
100
+ testID={testID ? `${testID}-actions` : undefined}
101
+ />
102
+ ))}
103
+ </View>
104
+ </RevealFade>
105
+ ) : null}
83
106
  </View>
84
107
  );
85
108
  }
@@ -4,7 +4,7 @@ import { AiChatContainerBase } from './AiChatContainer';
4
4
  import { AiChatFeedbackRowBase } from './AiChatFeedbackRowBase';
5
5
  import { AiChatGalleryPanelBase } from './AiChatGalleryPanelBase';
6
6
  import { AiChatImageGenerationBase } from './AiChatImageGenerationBase';
7
- import { AiChatAssistantMessageBase } from './AiChatMessages';
7
+ import { AiChatAssistantMessageBase, AiChatUserMessageBase } from './AiChatMessages';
8
8
  import { AiChatPlatformContext, type AiChatPlatform } from './context';
9
9
  import type {
10
10
  AiChatAssistantMessageProps,
@@ -12,6 +12,7 @@ import type {
12
12
  AiChatFeedbackRowProps,
13
13
  AiChatGalleryPanelProps,
14
14
  AiChatImageGenerationProps,
15
+ AiChatUserMessageProps,
15
16
  } from './types';
16
17
 
17
18
  /**
@@ -29,6 +30,15 @@ export function bindAiChat(platform: AiChatPlatform) {
29
30
  }
30
31
  AiChatFeedbackRow.displayName = 'AiChatFeedbackRow';
31
32
 
33
+ function AiChatUserMessage(props: AiChatUserMessageProps) {
34
+ return (
35
+ <AiChatPlatformContext.Provider value={platform}>
36
+ <AiChatUserMessageBase {...props} />
37
+ </AiChatPlatformContext.Provider>
38
+ );
39
+ }
40
+ AiChatUserMessage.displayName = 'AiChatUserMessage';
41
+
32
42
  function AiChatAssistantMessage(props: AiChatAssistantMessageProps) {
33
43
  return (
34
44
  <AiChatPlatformContext.Provider value={platform}>
@@ -65,5 +75,12 @@ export function bindAiChat(platform: AiChatPlatform) {
65
75
  }
66
76
  AiChatContainer.displayName = 'AiChatContainer';
67
77
 
68
- return { AiChatFeedbackRow, AiChatAssistantMessage, AiChatImageGeneration, AiChatGalleryPanel, AiChatContainer };
78
+ return {
79
+ AiChatFeedbackRow,
80
+ AiChatUserMessage,
81
+ AiChatAssistantMessage,
82
+ AiChatImageGeneration,
83
+ AiChatGalleryPanel,
84
+ AiChatContainer,
85
+ };
69
86
  }
@@ -4,6 +4,7 @@ export {
4
4
  AiChatFeedbackRow,
5
5
  AiChatGalleryPanel,
6
6
  AiChatImageGeneration,
7
+ AiChatUserMessage,
7
8
  } from './AiChat';
8
9
  export { AiChatCodePanel } from './AiChatCodePanel';
9
10
  export { AiChatThread } from './AiChatContainer';
@@ -13,7 +14,6 @@ export {
13
14
  AiChatLinkChip,
14
15
  AiChatMessageLine,
15
16
  AiChatStrong,
16
- AiChatUserMessage,
17
17
  } from './AiChatMessages';
18
18
  export { AiChatMobileHeader, AiChatResizeHandle, AiChatShell } from './AiChatShell';
19
19
  export { useAiChatChromeInsets, useAiChatShell } from './context';
@@ -27,6 +27,7 @@ export type {
27
27
  AiChatCodePanelProps,
28
28
  AiChatContainerLabels,
29
29
  AiChatContainerProps,
30
+ AiChatCopyResult,
30
31
  AiChatFeedbackLabels,
31
32
  AiChatFeedbackRowProps,
32
33
  AiChatGalleryPanelLabels,
@@ -46,5 +47,6 @@ export type {
46
47
  AiChatStrongProps,
47
48
  AiChatThreadHandle,
48
49
  AiChatThreadProps,
50
+ AiChatTurnAction,
49
51
  AiChatUserMessageProps,
50
52
  } from './types';
@@ -9,6 +9,7 @@ export {
9
9
  AiChatFeedbackRow,
10
10
  AiChatGalleryPanel,
11
11
  AiChatImageGeneration,
12
+ AiChatUserMessage,
12
13
  } from './AiChat.web';
13
14
  export { AiChatCodePanel } from './AiChatCodePanel';
14
15
  export { AiChatThread } from './AiChatContainer';
@@ -18,7 +19,6 @@ export {
18
19
  AiChatLinkChip,
19
20
  AiChatMessageLine,
20
21
  AiChatStrong,
21
- AiChatUserMessage,
22
22
  } from './AiChatMessages';
23
23
  export { AiChatMobileHeader, AiChatResizeHandle, AiChatShell } from './AiChatShell';
24
24
  export { useAiChatChromeInsets, useAiChatShell } from './context';
@@ -32,6 +32,7 @@ export type {
32
32
  AiChatCodePanelProps,
33
33
  AiChatContainerLabels,
34
34
  AiChatContainerProps,
35
+ AiChatCopyResult,
35
36
  AiChatFeedbackLabels,
36
37
  AiChatFeedbackRowProps,
37
38
  AiChatGalleryPanelLabels,
@@ -51,5 +52,6 @@ export type {
51
52
  AiChatStrongProps,
52
53
  AiChatThreadHandle,
53
54
  AiChatThreadProps,
55
+ AiChatTurnAction,
54
56
  AiChatUserMessageProps,
55
57
  } from './types';
@@ -176,7 +176,7 @@ const STYLE_ID = 'bloom-ai-chat-web-css';
176
176
  * - the split-flap countdown's two leaves (`flap-fall` / `flap-rise`);
177
177
  * - the generated image's feathered radial reveal, a registered `--bloom-ai-reveal`
178
178
  * percentage transitioning 0% → 132% over 1550ms;
179
- * - the resize grip's hover reveal;
179
+ * - the resize grip's hover reveal, and a user turn's actions (pointer only);
180
180
  * - the composer field's placeholder colour;
181
181
  * - a document-scrolled thread out of the browser's scroll anchoring.
182
182
  */
@@ -260,6 +260,14 @@ export const AI_CHAT_WEB_CSS = `
260
260
  [data-bloom-ai-chat-grip-pill="dragging"] { opacity: 1; }
261
261
  [data-bloom-ai-chat-grip] { cursor: col-resize; touch-action: none; outline: none; }
262
262
  [data-bloom-ai-chat-dragging="on"] { cursor: col-resize; user-select: none; }
263
+ @media (hover: hover) {
264
+ [data-bloom-ai-chat-turn] [data-bloom-ai-chat-turn-actions] {
265
+ opacity: 0;
266
+ transition: opacity ${TRANSITION_MS}ms ease;
267
+ }
268
+ [data-bloom-ai-chat-turn]:hover [data-bloom-ai-chat-turn-actions],
269
+ [data-bloom-ai-chat-turn]:focus-within [data-bloom-ai-chat-turn-actions] { opacity: 1; }
270
+ }
263
271
  [data-bloom-ai-chat-chip] {
264
272
  display: inline-flex;
265
273
  max-width: calc(100% - 4px);
@@ -275,7 +283,8 @@ export const AI_CHAT_WEB_CSS = `
275
283
  [data-bloom-ai-chat-control],
276
284
  [data-bloom-ai-chat-tile] [data-bloom-ai-chat-scrim],
277
285
  [data-bloom-ai-chat-tile-image],
278
- [data-bloom-ai-chat-grip] [data-bloom-ai-chat-grip-pill] { transition: none; }
286
+ [data-bloom-ai-chat-grip] [data-bloom-ai-chat-grip-pill],
287
+ [data-bloom-ai-chat-turn] [data-bloom-ai-chat-turn-actions] { transition: none; }
279
288
  [data-bloom-ai-chat-flap] { animation-duration: 1ms; animation-delay: 0ms; }
280
289
  }
281
290
  `;
@@ -30,14 +30,47 @@ export interface AiChatFeedbackLabels {
30
30
  copied?: string;
31
31
  }
32
32
 
33
+ /**
34
+ * What a copy handler reports. `void` (or `true`) is a copy that happened;
35
+ * `false` is one that did not. A promise is waited for: the confirmation shows
36
+ * once it resolves, and never if it resolves `false` or rejects.
37
+ */
38
+ export type AiChatCopyResult = void | boolean | Promise<void | boolean>;
39
+
40
+ /**
41
+ * One extra action on a turn — read aloud, regenerate, edit. Drawn with the
42
+ * feedback row's own button: a 16px glyph on a 28px square, a tooltip naming it.
43
+ */
44
+ export interface AiChatTurnAction {
45
+ /** Stable React key, and the suffix of the button's `testID`. */
46
+ key: string;
47
+ /** The accessible name, and the tooltip's copy. */
48
+ label: string;
49
+ /** A Bloom icon component (`RiVolumeUpLine`, not `<RiVolumeUpLine />`). */
50
+ icon: AiChatIconComponent;
51
+ onPress: () => void;
52
+ /**
53
+ * Makes the action a TOGGLE (read aloud while it is speaking): `true` paints
54
+ * the hover surface and the primary glyph, and announces it pressed. Leave it
55
+ * undefined for a plain button — `false` announces "not pressed".
56
+ */
57
+ active?: boolean;
58
+ /** Dimmed to the shared disabled opacity, unfocusable, announced disabled. */
59
+ disabled?: boolean;
60
+ }
61
+
33
62
  export interface AiChatFeedbackRowProps {
34
63
  onLike?: () => void;
35
64
  onDislike?: () => void;
36
65
  /**
37
- * Copy handler. The glyph swaps to a check and the tooltip reads "Copied!" for
38
- * 1.6s either way.
66
+ * Copy handler. The glyph swaps to a check and the tooltip reads "Copied!"
67
+ * for 1.6s once the copy has happened: at once for a handler that returns
68
+ * nothing (or `true`), after the promise resolves for an async one, and not
69
+ * at all when it returns or resolves `false`, throws or rejects.
39
70
  */
40
- onCopy?: () => void;
71
+ onCopy?: () => AiChatCopyResult;
72
+ /** More buttons after copy, in order — read aloud, regenerate… */
73
+ actions?: ReadonlyArray<AiChatTurnAction>;
41
74
  labels?: AiChatFeedbackLabels;
42
75
  style?: StyleProp<ViewStyle>;
43
76
  testID?: string;
@@ -50,6 +83,13 @@ export interface AiChatFeedbackRowProps {
50
83
  export interface AiChatUserMessageProps {
51
84
  /** `AiChatMessageLine`s — or a string, wrapped in one. */
52
85
  children: ReactNode;
86
+ /**
87
+ * Buttons under the card, right-aligned — copy, edit. On a web pointer they
88
+ * fade in while the turn is hovered or holds focus; they stay in the tab order
89
+ * and the accessibility tree throughout, and on native (and touch web) they
90
+ * are always shown.
91
+ */
92
+ actions?: ReadonlyArray<AiChatTurnAction>;
53
93
  /** Skip the blur-in (a turn restored from history). Default `true`. */
54
94
  animate?: boolean;
55
95
  style?: StyleProp<ViewStyle>;
@@ -64,7 +104,7 @@ export interface AiChatAssistantMessageProps {
64
104
  children: ReactNode;
65
105
  /** The like / dislike / copy row under the reply. Default `true`. */
66
106
  feedback?: boolean;
67
- /** Feedback handlers and labels. */
107
+ /** Feedback handlers, extra `actions` and labels. */
68
108
  feedbackProps?: Omit<AiChatFeedbackRowProps, 'style' | 'testID'>;
69
109
  /** Skip the staggered blur-in. Default `true`. */
70
110
  animate?: boolean;
@@ -141,7 +181,7 @@ export interface AiChatImageGenerationProps {
141
181
  /** Feedback handlers under the landed image. */
142
182
  onLike?: () => void;
143
183
  onDislike?: () => void;
144
- onCopy?: () => void;
184
+ onCopy?: () => AiChatCopyResult;
145
185
  labels?: AiChatImageGenerationLabels;
146
186
  feedbackLabels?: AiChatFeedbackLabels;
147
187
  style?: StyleProp<ViewStyle>;
@@ -4,6 +4,7 @@ import { resolveIconSlot } from '../icons/render-icon';
4
4
  import {
5
5
  ActivityIndicator,
6
6
  Linking,
7
+ Platform,
7
8
  Pressable,
8
9
  StyleSheet,
9
10
  View,
@@ -80,6 +81,42 @@ const SQUARE_HIT_SLOP = {
80
81
  lg: { top: 0, bottom: 0, left: 0, right: 0 },
81
82
  } as const satisfies Record<ButtonResolvedSize, NonNullable<ButtonProps['hitSlop']>>;
82
83
 
84
+ type HitSlop = { top: number; bottom: number; left: number; right: number };
85
+
86
+ /**
87
+ * Android's floor is 48dp (Material), 4dp above the 44 the tables above reach —
88
+ * so on Android every target grows 2dp at its top and bottom: `lg` (44dp tall,
89
+ * no slack) reaches 48, and every smaller size clears 48 the same way. Squares
90
+ * also grow 2dp at the sides, since they already take all-round slack; row
91
+ * buttons and links do not, because side slack steals presses from a
92
+ * neighbour. Two stacked buttons keep 4dp of every 8dp gap between them.
93
+ */
94
+ const ANDROID_TARGET_EXTRA = 2;
95
+
96
+ function growVertical(slop: HitSlop): HitSlop {
97
+ return { ...slop, top: slop.top + ANDROID_TARGET_EXTRA, bottom: slop.bottom + ANDROID_TARGET_EXTRA };
98
+ }
99
+
100
+ function growAllRound(slop: HitSlop): HitSlop {
101
+ return {
102
+ top: slop.top + ANDROID_TARGET_EXTRA,
103
+ bottom: slop.bottom + ANDROID_TARGET_EXTRA,
104
+ left: slop.left + ANDROID_TARGET_EXTRA,
105
+ right: slop.right + ANDROID_TARGET_EXTRA,
106
+ };
107
+ }
108
+
109
+ function mapSizes(
110
+ table: Record<ButtonResolvedSize, HitSlop>,
111
+ grow: (slop: HitSlop) => HitSlop,
112
+ ): Record<ButtonResolvedSize, HitSlop> {
113
+ return { xs: grow(table.xs), sm: grow(table.sm), md: grow(table.md), lg: grow(table.lg) };
114
+ }
115
+
116
+ const ANDROID_SIZE_HIT_SLOP = mapSizes(SIZE_HIT_SLOP, growVertical);
117
+ const ANDROID_SQUARE_HIT_SLOP = mapSizes(SQUARE_HIT_SLOP, growAllRound);
118
+ const ANDROID_LINK_HIT_SLOP = growVertical(LINK_HIT_SLOP);
119
+
83
120
  // ---------------------------------------------------------------------------
84
121
  // The button renders ONE node — the same shape the web fork renders (one
85
122
  // `<button>`), so a caller's layout `className` lands on the box its parent
@@ -286,11 +323,12 @@ const ButtonComponent: React.FC<ButtonProps> = ({
286
323
  [geometry, paint.foreground, isLink, underlineMode, hovered, disabled],
287
324
  );
288
325
 
326
+ const android = Platform.OS === 'android';
289
327
  const defaultHitSlop = isSquare
290
- ? SQUARE_HIT_SLOP[size]
328
+ ? (android ? ANDROID_SQUARE_HIT_SLOP : SQUARE_HIT_SLOP)[size]
291
329
  : isLink
292
- ? LINK_HIT_SLOP
293
- : SIZE_HIT_SLOP[size];
330
+ ? (android ? ANDROID_LINK_HIT_SLOP : LINK_HIT_SLOP)
331
+ : (android ? ANDROID_SIZE_HIT_SLOP : SIZE_HIT_SLOP)[size];
294
332
 
295
333
  const IconFromProp = icon != null && isIconComponent(icon) ? icon : null;
296
334
  const iconNode = IconFromProp ? (