@oxy.so/bloom 4.19.1 → 4.20.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 (134) hide show
  1. package/docs/adoption-matrix.mdx +3 -3
  2. package/docs/ai-chat.mdx +32 -1
  3. package/docs/composer-panel.mdx +4 -0
  4. package/lib/commonjs/ai-chat/AiChat.js +2 -1
  5. package/lib/commonjs/ai-chat/AiChat.js.map +1 -1
  6. package/lib/commonjs/ai-chat/AiChat.web.js +2 -1
  7. package/lib/commonjs/ai-chat/AiChat.web.js.map +1 -1
  8. package/lib/commonjs/ai-chat/AiChatControls.js +60 -4
  9. package/lib/commonjs/ai-chat/AiChatControls.js.map +1 -1
  10. package/lib/commonjs/ai-chat/AiChatFeedbackRowBase.js +40 -12
  11. package/lib/commonjs/ai-chat/AiChatFeedbackRowBase.js.map +1 -1
  12. package/lib/commonjs/ai-chat/AiChatMessages.js +31 -5
  13. package/lib/commonjs/ai-chat/AiChatMessages.js.map +1 -1
  14. package/lib/commonjs/ai-chat/bindAiChat.js +10 -0
  15. package/lib/commonjs/ai-chat/bindAiChat.js.map +1 -1
  16. package/lib/commonjs/ai-chat/index.js +1 -1
  17. package/lib/commonjs/ai-chat/index.js.map +1 -1
  18. package/lib/commonjs/ai-chat/index.web.js +1 -1
  19. package/lib/commonjs/ai-chat/index.web.js.map +1 -1
  20. package/lib/commonjs/ai-chat/shared.js +11 -2
  21. package/lib/commonjs/ai-chat/shared.js.map +1 -1
  22. package/lib/commonjs/composer-panel/AttachmentStrip.js +108 -8
  23. package/lib/commonjs/composer-panel/AttachmentStrip.js.map +1 -1
  24. package/lib/commonjs/composer-panel/ComposerPanelBase.js +6 -2
  25. package/lib/commonjs/composer-panel/ComposerPanelBase.js.map +1 -1
  26. package/lib/commonjs/composer-panel/ComposerPillBase.js +2 -1
  27. package/lib/commonjs/composer-panel/ComposerPillBase.js.map +1 -1
  28. package/lib/commonjs/composer-panel/shared.js +2 -0
  29. package/lib/commonjs/composer-panel/shared.js.map +1 -1
  30. package/lib/commonjs/composer-panel/use-attachment-queue.js +5 -2
  31. package/lib/commonjs/composer-panel/use-attachment-queue.js.map +1 -1
  32. package/lib/module/ai-chat/AiChat.js +1 -0
  33. package/lib/module/ai-chat/AiChat.js.map +1 -1
  34. package/lib/module/ai-chat/AiChat.web.js +1 -0
  35. package/lib/module/ai-chat/AiChat.web.js.map +1 -1
  36. package/lib/module/ai-chat/AiChatControls.js +58 -4
  37. package/lib/module/ai-chat/AiChatControls.js.map +1 -1
  38. package/lib/module/ai-chat/AiChatFeedbackRowBase.js +41 -13
  39. package/lib/module/ai-chat/AiChatFeedbackRowBase.js.map +1 -1
  40. package/lib/module/ai-chat/AiChatMessages.js +30 -4
  41. package/lib/module/ai-chat/AiChatMessages.js.map +1 -1
  42. package/lib/module/ai-chat/bindAiChat.js +11 -1
  43. package/lib/module/ai-chat/bindAiChat.js.map +1 -1
  44. package/lib/module/ai-chat/index.js +2 -2
  45. package/lib/module/ai-chat/index.js.map +1 -1
  46. package/lib/module/ai-chat/index.web.js +2 -2
  47. package/lib/module/ai-chat/index.web.js.map +1 -1
  48. package/lib/module/ai-chat/shared.js +11 -2
  49. package/lib/module/ai-chat/shared.js.map +1 -1
  50. package/lib/module/composer-panel/AttachmentStrip.js +108 -8
  51. package/lib/module/composer-panel/AttachmentStrip.js.map +1 -1
  52. package/lib/module/composer-panel/ComposerPanelBase.js +6 -2
  53. package/lib/module/composer-panel/ComposerPanelBase.js.map +1 -1
  54. package/lib/module/composer-panel/ComposerPillBase.js +2 -1
  55. package/lib/module/composer-panel/ComposerPillBase.js.map +1 -1
  56. package/lib/module/composer-panel/shared.js +2 -0
  57. package/lib/module/composer-panel/shared.js.map +1 -1
  58. package/lib/module/composer-panel/use-attachment-queue.js +5 -2
  59. package/lib/module/composer-panel/use-attachment-queue.js.map +1 -1
  60. package/lib/typescript/commonjs/ai-chat/AiChat.d.ts +4 -0
  61. package/lib/typescript/commonjs/ai-chat/AiChat.d.ts.map +1 -1
  62. package/lib/typescript/commonjs/ai-chat/AiChat.web.d.ts +4 -0
  63. package/lib/typescript/commonjs/ai-chat/AiChat.web.d.ts.map +1 -1
  64. package/lib/typescript/commonjs/ai-chat/AiChatControls.d.ts +22 -1
  65. package/lib/typescript/commonjs/ai-chat/AiChatControls.d.ts.map +1 -1
  66. package/lib/typescript/commonjs/ai-chat/AiChatFeedbackRowBase.d.ts +5 -2
  67. package/lib/typescript/commonjs/ai-chat/AiChatFeedbackRowBase.d.ts.map +1 -1
  68. package/lib/typescript/commonjs/ai-chat/AiChatMessages.d.ts +1 -1
  69. package/lib/typescript/commonjs/ai-chat/AiChatMessages.d.ts.map +1 -1
  70. package/lib/typescript/commonjs/ai-chat/bindAiChat.d.ts +5 -1
  71. package/lib/typescript/commonjs/ai-chat/bindAiChat.d.ts.map +1 -1
  72. package/lib/typescript/commonjs/ai-chat/index.d.ts +3 -3
  73. package/lib/typescript/commonjs/ai-chat/index.d.ts.map +1 -1
  74. package/lib/typescript/commonjs/ai-chat/index.web.d.ts +3 -3
  75. package/lib/typescript/commonjs/ai-chat/index.web.d.ts.map +1 -1
  76. package/lib/typescript/commonjs/ai-chat/shared.d.ts +2 -2
  77. package/lib/typescript/commonjs/ai-chat/shared.d.ts.map +1 -1
  78. package/lib/typescript/commonjs/ai-chat/types.d.ts +43 -5
  79. package/lib/typescript/commonjs/ai-chat/types.d.ts.map +1 -1
  80. package/lib/typescript/commonjs/composer-panel/AttachmentStrip.d.ts +10 -1
  81. package/lib/typescript/commonjs/composer-panel/AttachmentStrip.d.ts.map +1 -1
  82. package/lib/typescript/commonjs/composer-panel/ComposerPanelBase.d.ts +1 -1
  83. package/lib/typescript/commonjs/composer-panel/ComposerPanelBase.d.ts.map +1 -1
  84. package/lib/typescript/commonjs/composer-panel/shared.d.ts +3 -0
  85. package/lib/typescript/commonjs/composer-panel/shared.d.ts.map +1 -1
  86. package/lib/typescript/commonjs/composer-panel/types.d.ts +11 -0
  87. package/lib/typescript/commonjs/composer-panel/types.d.ts.map +1 -1
  88. package/lib/typescript/commonjs/composer-panel/use-attachment-queue.d.ts.map +1 -1
  89. package/lib/typescript/module/ai-chat/AiChat.d.ts +4 -0
  90. package/lib/typescript/module/ai-chat/AiChat.d.ts.map +1 -1
  91. package/lib/typescript/module/ai-chat/AiChat.web.d.ts +4 -0
  92. package/lib/typescript/module/ai-chat/AiChat.web.d.ts.map +1 -1
  93. package/lib/typescript/module/ai-chat/AiChatControls.d.ts +22 -1
  94. package/lib/typescript/module/ai-chat/AiChatControls.d.ts.map +1 -1
  95. package/lib/typescript/module/ai-chat/AiChatFeedbackRowBase.d.ts +5 -2
  96. package/lib/typescript/module/ai-chat/AiChatFeedbackRowBase.d.ts.map +1 -1
  97. package/lib/typescript/module/ai-chat/AiChatMessages.d.ts +1 -1
  98. package/lib/typescript/module/ai-chat/AiChatMessages.d.ts.map +1 -1
  99. package/lib/typescript/module/ai-chat/bindAiChat.d.ts +5 -1
  100. package/lib/typescript/module/ai-chat/bindAiChat.d.ts.map +1 -1
  101. package/lib/typescript/module/ai-chat/index.d.ts +3 -3
  102. package/lib/typescript/module/ai-chat/index.d.ts.map +1 -1
  103. package/lib/typescript/module/ai-chat/index.web.d.ts +3 -3
  104. package/lib/typescript/module/ai-chat/index.web.d.ts.map +1 -1
  105. package/lib/typescript/module/ai-chat/shared.d.ts +2 -2
  106. package/lib/typescript/module/ai-chat/shared.d.ts.map +1 -1
  107. package/lib/typescript/module/ai-chat/types.d.ts +43 -5
  108. package/lib/typescript/module/ai-chat/types.d.ts.map +1 -1
  109. package/lib/typescript/module/composer-panel/AttachmentStrip.d.ts +10 -1
  110. package/lib/typescript/module/composer-panel/AttachmentStrip.d.ts.map +1 -1
  111. package/lib/typescript/module/composer-panel/ComposerPanelBase.d.ts +1 -1
  112. package/lib/typescript/module/composer-panel/ComposerPanelBase.d.ts.map +1 -1
  113. package/lib/typescript/module/composer-panel/shared.d.ts +3 -0
  114. package/lib/typescript/module/composer-panel/shared.d.ts.map +1 -1
  115. package/lib/typescript/module/composer-panel/types.d.ts +11 -0
  116. package/lib/typescript/module/composer-panel/types.d.ts.map +1 -1
  117. package/lib/typescript/module/composer-panel/use-attachment-queue.d.ts.map +1 -1
  118. package/package.json +1 -1
  119. package/src/ai-chat/AiChat.tsx +1 -0
  120. package/src/ai-chat/AiChat.web.tsx +1 -0
  121. package/src/ai-chat/AiChatControls.tsx +58 -3
  122. package/src/ai-chat/AiChatFeedbackRowBase.tsx +45 -17
  123. package/src/ai-chat/AiChatMessages.tsx +24 -1
  124. package/src/ai-chat/bindAiChat.tsx +19 -2
  125. package/src/ai-chat/index.ts +3 -1
  126. package/src/ai-chat/index.web.ts +3 -1
  127. package/src/ai-chat/shared.ts +11 -2
  128. package/src/ai-chat/types.ts +45 -5
  129. package/src/composer-panel/AttachmentStrip.tsx +98 -6
  130. package/src/composer-panel/ComposerPanelBase.tsx +4 -0
  131. package/src/composer-panel/ComposerPillBase.tsx +1 -0
  132. package/src/composer-panel/shared.ts +5 -0
  133. package/src/composer-panel/types.ts +11 -0
  134. package/src/composer-panel/use-attachment-queue.ts +5 -2
@@ -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>;
@@ -10,6 +10,8 @@ import Animated, {
10
10
  import Svg, { Path } from 'react-native-svg';
11
11
 
12
12
  import { mixColor } from '../button/shared';
13
+ import { RiErrorWarningLine } from '../icons/remix/RiErrorWarningLine';
14
+ import { RiRefreshLine } from '../icons/remix/RiRefreshLine';
13
15
  import { Text } from '../typography';
14
16
  import type { WebCssStyle } from '../styles/web-view-style';
15
17
  import {
@@ -101,6 +103,41 @@ function TileDismiss({
101
103
  );
102
104
  }
103
105
 
106
+ /**
107
+ * A failed tile's retry: a 24px disc on the card surface where the kind glyph
108
+ * sits (3, 4), a 14px refresh glyph in the error foreground, text-primary on
109
+ * hover.
110
+ */
111
+ function TileRetry({ label, onPress, palette }: { label: string; onPress: () => void; palette: ComposerPalette }) {
112
+ const [hovered, setHovered] = useState(false);
113
+ const style: WebCssStyle = {
114
+ position: 'absolute',
115
+ top: 3,
116
+ left: 3,
117
+ width: 24,
118
+ height: 24,
119
+ borderRadius: 12,
120
+ alignItems: 'center',
121
+ justifyContent: 'center',
122
+ backgroundColor: palette.surface,
123
+ cursor: 'pointer',
124
+ '--bloom-composer-ring': palette.focusRing,
125
+ };
126
+ return (
127
+ <Pressable
128
+ {...dataHook('bloomComposerControl', 'offset')}
129
+ accessibilityRole="button"
130
+ accessibilityLabel={label}
131
+ onPress={onPress}
132
+ onHoverIn={() => setHovered(true)}
133
+ onHoverOut={() => setHovered(false)}
134
+ hitSlop={4}
135
+ style={style}>
136
+ <RiRefreshLine width={14} height={14} fill={hovered ? palette.text : palette.errorText} />
137
+ </Pressable>
138
+ );
139
+ }
140
+
104
141
  /** The upload percentage's box: top 3 / right 3, blurring out as it fades. */
105
142
  function percentStyle(inFlight: boolean): WebCssStyle {
106
143
  return {
@@ -123,7 +160,9 @@ interface TileProps {
123
160
  attachment: ComposerPanelAttachment;
124
161
  palette: ComposerPalette;
125
162
  onRemove?: () => void;
163
+ onRetry?: () => void;
126
164
  removeLabel: string;
165
+ retryLabel: string;
127
166
  }
128
167
 
129
168
  /**
@@ -134,9 +173,23 @@ interface TileProps {
134
173
  * percentage (9/16 medium, tabular; white over a photo, accent-500 otherwise)
135
174
  * sits at top 3 / right 3; once landed it blurs out as the dismiss blurs in on
136
175
  * the same spot.
176
+ *
177
+ * A FAILED tile (`error`) drops the ring and percentage and fills with the
178
+ * error subtle surface under an error-foreground border: the warning glyph (or
179
+ * the retry button) at (3, 4) and the message in 9/11 medium, two lines at
180
+ * most, from (5, 30). The dismiss shows as on a landed tile.
137
181
  */
138
- export const AttachmentTile = memo(function AttachmentTile({ attachment, palette, onRemove, removeLabel }: TileProps) {
139
- const { progress } = attachment;
182
+ export const AttachmentTile = memo(function AttachmentTile({
183
+ attachment,
184
+ palette,
185
+ onRemove,
186
+ onRetry,
187
+ removeLabel,
188
+ retryLabel,
189
+ }: TileProps) {
190
+ const { error } = attachment;
191
+ const failed = !!error;
192
+ const progress = failed ? undefined : attachment.progress;
140
193
  const inFlight = progress !== undefined;
141
194
  const image = attachment.kind === 'image' ? attachment.src : undefined;
142
195
  const dash = inFlight && progress < 100 ? (Math.max(0, progress) / 100) * TILE_RING_LENGTH : TILE_RING_LENGTH + 2;
@@ -144,8 +197,8 @@ export const AttachmentTile = memo(function AttachmentTile({ attachment, palette
144
197
 
145
198
  return (
146
199
  <View
147
- accessibilityLabel={attachment.name}
148
- {...(IS_WEB ? { title: attachment.name } : {})}
200
+ accessibilityLabel={failed ? `${attachment.name}: ${error}` : attachment.name}
201
+ {...(IS_WEB ? { title: failed ? `${attachment.name}: ${error}` : attachment.name } : {})}
149
202
  style={{
150
203
  position: 'relative',
151
204
  width: TILE,
@@ -153,7 +206,7 @@ export const AttachmentTile = memo(function AttachmentTile({ attachment, palette
153
206
  flexShrink: 0,
154
207
  borderRadius: TILE_RADIUS,
155
208
  borderWidth: 1,
156
- borderColor: palette.tileBorder,
209
+ borderColor: failed ? palette.errorText : palette.tileBorder,
157
210
  }}>
158
211
  {image ? (
159
212
  <View
@@ -196,6 +249,41 @@ export const AttachmentTile = memo(function AttachmentTile({ attachment, palette
196
249
  </View>
197
250
  )}
198
251
 
252
+ {failed ? (
253
+ <View
254
+ style={{
255
+ position: 'absolute',
256
+ top: 0,
257
+ left: 0,
258
+ right: 0,
259
+ bottom: 0,
260
+ borderRadius: TILE_RADIUS - 1,
261
+ backgroundColor: palette.errorSurface,
262
+ }}>
263
+ {onRetry ? null : (
264
+ <View style={{ position: 'absolute', top: 4, left: 3, width: 24, height: 24, alignItems: 'center', justifyContent: 'center' }}>
265
+ <RiErrorWarningLine width={20} height={20} fill={palette.errorText} />
266
+ </View>
267
+ )}
268
+ <Text
269
+ numberOfLines={2}
270
+ style={{
271
+ position: 'absolute',
272
+ top: 30,
273
+ left: 5,
274
+ right: 5,
275
+ // 9px has no type-ramp step; spelled out literally.
276
+ fontSize: 9,
277
+ lineHeight: 11,
278
+ fontWeight: '500',
279
+ color: palette.errorText,
280
+ }}>
281
+ {error}
282
+ </Text>
283
+ {onRetry ? <TileRetry label={`${retryLabel} ${attachment.name}`} onPress={onRetry} palette={palette} /> : null}
284
+ </View>
285
+ ) : null}
286
+
199
287
  {/* Upload ring over the border: the svg spans the border box. */}
200
288
  <View
201
289
  pointerEvents="none"
@@ -288,7 +376,9 @@ interface StripProps {
288
376
  attachments: ReadonlyArray<ComposerPanelAttachment>;
289
377
  palette: ComposerPalette;
290
378
  onRemove?: (id: string) => void;
379
+ onRetry?: (id: string) => void;
291
380
  removeLabel: string;
381
+ retryLabel: string;
292
382
  }
293
383
 
294
384
  /**
@@ -296,7 +386,7 @@ interface StripProps {
296
386
  * scale 0.8 → 1, fade and un-blur on arrival over 280ms `cubic-bezier(0.22, 1,
297
387
  * 0.36, 1)`, and play it backwards on dismissal before leaving the row.
298
388
  */
299
- export function AttachmentStrip({ attachments, palette, onRemove, removeLabel }: StripProps) {
389
+ export function AttachmentStrip({ attachments, palette, onRemove, onRetry, removeLabel, retryLabel }: StripProps) {
300
390
  const [leaving, setLeaving] = useState<ReadonlyArray<ComposerPanelAttachment>>([]);
301
391
  const previous = useRef(attachments);
302
392
 
@@ -330,7 +420,9 @@ export function AttachmentStrip({ attachments, palette, onRemove, removeLabel }:
330
420
  attachment={attachment}
331
421
  palette={palette}
332
422
  removeLabel={removeLabel}
423
+ retryLabel={retryLabel}
333
424
  onRemove={onRemove && !isLeaving ? () => onRemove(attachment.id) : undefined}
425
+ onRetry={onRetry && !isLeaving ? () => onRetry(attachment.id) : undefined}
334
426
  />
335
427
  </PresenceTile>
336
428
  ))}