@oxy.so/bloom 4.8.0 → 4.9.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 (44) hide show
  1. package/docs/adoption-matrix.mdx +2 -2
  2. package/docs/ai-chat.mdx +21 -0
  3. package/lib/commonjs/ai-chat/AiChatContainer.js +384 -77
  4. package/lib/commonjs/ai-chat/AiChatContainer.js.map +1 -1
  5. package/lib/commonjs/ai-chat/context.js +28 -1
  6. package/lib/commonjs/ai-chat/context.js.map +1 -1
  7. package/lib/commonjs/ai-chat/index.js +6 -0
  8. package/lib/commonjs/ai-chat/index.js.map +1 -1
  9. package/lib/commonjs/ai-chat/index.web.js +6 -0
  10. package/lib/commonjs/ai-chat/index.web.js.map +1 -1
  11. package/lib/module/ai-chat/AiChatContainer.js +386 -79
  12. package/lib/module/ai-chat/AiChatContainer.js.map +1 -1
  13. package/lib/module/ai-chat/context.js +26 -0
  14. package/lib/module/ai-chat/context.js.map +1 -1
  15. package/lib/module/ai-chat/index.js +1 -1
  16. package/lib/module/ai-chat/index.js.map +1 -1
  17. package/lib/module/ai-chat/index.web.js +1 -1
  18. package/lib/module/ai-chat/index.web.js.map +1 -1
  19. package/lib/typescript/commonjs/ai-chat/AiChatContainer.d.ts +24 -1
  20. package/lib/typescript/commonjs/ai-chat/AiChatContainer.d.ts.map +1 -1
  21. package/lib/typescript/commonjs/ai-chat/context.d.ts +32 -0
  22. package/lib/typescript/commonjs/ai-chat/context.d.ts.map +1 -1
  23. package/lib/typescript/commonjs/ai-chat/index.d.ts +2 -2
  24. package/lib/typescript/commonjs/ai-chat/index.d.ts.map +1 -1
  25. package/lib/typescript/commonjs/ai-chat/index.web.d.ts +2 -2
  26. package/lib/typescript/commonjs/ai-chat/index.web.d.ts.map +1 -1
  27. package/lib/typescript/commonjs/ai-chat/types.d.ts +17 -0
  28. package/lib/typescript/commonjs/ai-chat/types.d.ts.map +1 -1
  29. package/lib/typescript/module/ai-chat/AiChatContainer.d.ts +24 -1
  30. package/lib/typescript/module/ai-chat/AiChatContainer.d.ts.map +1 -1
  31. package/lib/typescript/module/ai-chat/context.d.ts +32 -0
  32. package/lib/typescript/module/ai-chat/context.d.ts.map +1 -1
  33. package/lib/typescript/module/ai-chat/index.d.ts +2 -2
  34. package/lib/typescript/module/ai-chat/index.d.ts.map +1 -1
  35. package/lib/typescript/module/ai-chat/index.web.d.ts +2 -2
  36. package/lib/typescript/module/ai-chat/index.web.d.ts.map +1 -1
  37. package/lib/typescript/module/ai-chat/types.d.ts +17 -0
  38. package/lib/typescript/module/ai-chat/types.d.ts.map +1 -1
  39. package/package.json +1 -1
  40. package/src/ai-chat/AiChatContainer.tsx +343 -59
  41. package/src/ai-chat/context.ts +39 -0
  42. package/src/ai-chat/index.ts +2 -2
  43. package/src/ai-chat/index.web.ts +2 -2
  44. package/src/ai-chat/types.ts +17 -0
@@ -278,7 +278,7 @@ The classification is an EQUALITY in `src/__tests__/adoption-matrix.test.ts`, no
278
278
  | `segmented-control` | `@oxy.so/bloom/segmented-control` + barrel | universal | `segmented-control.mdx` | yes | 4 | reads | | | |
279
279
  | `select` | `@oxy.so/bloom/select` + barrel | web fork | `select.mdx` | yes | 7 | reads | | reads | |
280
280
  | `settings-list` | `@oxy.so/bloom/settings-list` + barrel | universal | `settings-list.mdx` | yes | 5 | | | | |
281
- | `settings-modal` | `@oxy.so/bloom/settings-modal` + barrel | universal | `settings-modal.mdx` | yes | 2 | | | | reads |
281
+ | `settings-modal` | `@oxy.so/bloom/settings-modal` + barrel | universal | `settings-modal.mdx` | yes | 3 | | | | reads |
282
282
  | `shipment-request` | `@oxy.so/bloom/shipment-request` + barrel | universal | `shipment-request.mdx` | yes | 1 | **adaptation** | | | |
283
283
  | `sidebar` | `@oxy.so/bloom/sidebar` + barrel | universal | `sidebar.mdx` | yes | 6 | n/a¹ | | | reads |
284
284
  | `skeleton` | `@oxy.so/bloom/skeleton` + barrel | universal | `skeleton.mdx` | yes | 1 | | | | |
@@ -304,7 +304,7 @@ The classification is an EQUALITY in `src/__tests__/adoption-matrix.test.ts`, no
304
304
  | `tenancy` | `@oxy.so/bloom/tenancy` + barrel | universal | `tenancy.mdx` | yes | 3 | | n/a¹ | | |
305
305
  | `text-field` | `@oxy.so/bloom/text-field` + barrel | universal | `text-field.mdx` | yes | 13 | reads | reads | | |
306
306
  | `textarea` | `@oxy.so/bloom/textarea` + barrel | universal | `textarea.mdx` | yes | 4 | reads | reads | | n/a¹ |
307
- | `theme` | `@oxy.so/bloom/theme` + barrel | web fork | `theme.mdx` | yes | 294 | | | n/a¹ | reads |
307
+ | `theme` | `@oxy.so/bloom/theme` + barrel | web fork | `theme.mdx` | yes | 295 | | | n/a¹ | reads |
308
308
  | `theme-toggle` | `@oxy.so/bloom/theme-toggle` + barrel | universal | `theme-toggle.mdx` | yes | 1 | n/a¹ | | | |
309
309
  | `toast` | `@oxy.so/bloom/toast` + barrel | universal | `toast.mdx` | yes | 17 | | | | |
310
310
  | `tooltip` | `@oxy.so/bloom/tooltip` + barrel | web fork | `tooltip.mdx` | yes | 1 | | | | |
package/docs/ai-chat.mdx CHANGED
@@ -95,6 +95,27 @@ The thread scrolls with a thin scrollbar, px 16 / pt 16, turns 12 apart, and is
95
95
 
96
96
  **Hazard (web):** react-native-web gives every view `min-height: 0`, so the usual `flexGrow: 1` + `justifyContent: 'flex-end'` content container stays the viewport's height and its children overflow it instead of scrolling — and `onContentSizeChange` never sees the growth. The thread floors its container at `minHeight: '100%'` on web instead.
97
97
 
98
+ ### Floating chrome
99
+
100
+ `<AiChatContainer floatingChrome>` keeps every part and changes the geometry: the conversation fills the **whole card** and scrolls **behind** the header and **behind** the composer. Use it when the transcript is the screen — a full-height chat where two opaque bands would each take a strip of it, or where a `background` layer should run edge to edge under everything. Leave it off (the default, and what the AI Chat template uses) for the stacked layout above.
101
+
102
+ | Layer | Geometry |
103
+ |---|---|
104
+ | card | unchanged: radius 24, background-secondary (or `surface={false}`), clipped; `background` still fills it under everything |
105
+ | `children` | fill the card's full height — nothing is in flow above or below them |
106
+ | header block | the `header` slot (`AiChatMobileHeader`) + the breadcrumb row, absolute at the top, transparent — no band of its own |
107
+ | footer block | `AgentThinking` + `composer`, absolute at the bottom; pt **24** (instead of 12), px 10 / pb 10, gap 10 |
108
+ | top fade | the page-header edge scrim in the card surface, opaque at the top edge, ending 55% of the header block's height below it; fades in over the first **16px** scrolled (the gap it closes) |
109
+ | bottom fade | a 24px scrim ramp across the footer's top padding, then solid surface behind the composer; fades in over the first **24px** of transcript below the fold |
110
+
111
+ Both blocks are `pointerEvents="box-none"`, so the transcript under their empty space still scrolls and takes clicks. Both fades are decorative (hidden from assistive tech) and exist **only while turns are actually under that edge** — at rest, with the thread followed to the end, the bottom is clear and a `background` layer shows untinted.
112
+
113
+ **Insets.** The container measures both blocks (`onLayout`) and hands the heights to its children through context. An `AiChatThread` pads its **content** by them — pt = header block + 16, pb = footer block — and sets matching iOS `scrollIndicatorInsets`, so at rest the first and last turns sit exactly where the stacked layout puts them relative to the chrome, and in motion pass under it. The `ScrollView` stays the only scroller; `autoFollow`, `followThreshold`, `onStartReached`, `maintainStartPosition` and the ref work unchanged. A resize that is **exactly** the chrome's (the composer growing, `AgentThinking` appearing, the header measuring in on mount) is not a new turn: a reader at the end stays at the end without an animated follow, and a reader mid-thread keeps their turn in place. Any other child reads the same numbers from `useAiChatChromeInsets()` (`{ top, bottom }`, or `null` outside a floating container) to keep an empty state or a scroller of its own clear of the chrome.
114
+
115
+ **Why a fade, not a frost.** `AgentChat` frosts its overlaid header (white/black 20%, a 20px backdrop blur on web) once the transcript reaches it. Here the chrome has no band, and a frosted rectangle is one: it ends at its own bottom edge, and that edge is a line across the transcript. A one-colour opacity ramp is identical on native and web and needs no blur.
116
+
117
+ **Limits.** The fades are background-secondary. With `surface={false}` there is no known colour to fade to, so the chrome floats **without** fades — the composer's own surface is then what covers the text under it. Over an opaque `background` layer the fades are the card surface, not the layer.
118
+
98
119
  ## Turns
99
120
 
100
121
  - **User** — pushed right with a 6px bleed past the column, as wide as its text up to half the column + 6; radius 16, background-primary, px 12 / py 11, shadow-card.
@@ -7,12 +7,17 @@ exports.AiChatContainer = AiChatContainer;
7
7
  exports.AiChatThread = void 0;
8
8
  var _react = _interopRequireWildcard(require("react"));
9
9
  var _reactNative = require("react-native");
10
+ var _reactNativeReanimated = _interopRequireWildcard(require("react-native-reanimated"));
10
11
  var _index = require("../agent-thinking/index.js");
11
12
  var _index2 = require("../breadcrumb/index.js");
12
13
  var _RiFolderLine = require("../icons/remix/RiFolderLine.js");
13
14
  var _RiMoreFill = require("../icons/remix/RiMoreFill.js");
14
15
  var _RiShare2Line = require("../icons/remix/RiShare2Line.js");
16
+ var _EdgeScrim = require("../page-header/EdgeScrim.js");
17
+ var _colorUtils = require("../theme/color-utils.js");
18
+ var _useIsomorphicLayoutEffect = require("../theme/use-isomorphic-layout-effect.js");
15
19
  var _AiChatControls = require("./AiChatControls.js");
20
+ var _context = require("./context.js");
16
21
  var _shared = require("./shared.js");
17
22
  var _jsxRuntime = require("react/jsx-runtime");
18
23
  function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); }
@@ -22,6 +27,22 @@ const DEFAULT_LABELS = {
22
27
  more: 'More options'
23
28
  };
24
29
 
30
+ /** The thread's own top padding: the gap between the header and the first turn. */
31
+ const THREAD_PAD_TOP = 16;
32
+ /**
33
+ * `floatingChrome`: the footer's fade strip, px — the footer's top padding, so
34
+ * the last turn at rest ends where the fade begins, and the distance over which
35
+ * the bottom fade ramps in as the transcript slides under it.
36
+ */
37
+ const FOOTER_FADE = 24;
38
+ /** Below this many px of overlap an edge counts as clear (sub-pixel scroll noise). */
39
+ const EDGE_THRESHOLD = 1;
40
+ const HIDDEN_FROM_A11Y = {
41
+ 'aria-hidden': true,
42
+ accessibilityElementsHidden: true,
43
+ importantForAccessibility: 'no-hide-descendants'
44
+ };
45
+
25
46
  /**
26
47
  * The AI chat's centre column: the centre column of the AI chat template.
27
48
  *
@@ -39,6 +60,22 @@ const DEFAULT_LABELS = {
39
60
  *
40
61
  * `project` is optional: without one the breadcrumb is the chat's own crumb
41
62
  * alone, since a chat that belongs to nothing must not have to invent a folder.
63
+ *
64
+ * `floatingChrome` changes the geometry, not the parts:
65
+ *
66
+ * thread the `children` fill the whole card, under everything below
67
+ * header the `header` slot + breadcrumb row, absolute at the top, no band;
68
+ * an edge scrim of the surface (opaque at the top, gone
69
+ * `SCRIM_TAIL_RATIO` below the block) fades in over the first 16px
70
+ * of transcript scrolled under it
71
+ * footer absolute at the bottom, pt 24 instead of 12: the 24px strip is a
72
+ * scrim ramp and the rest is solid surface, faded in over the first
73
+ * 24px of transcript below the fold
74
+ *
75
+ * Both blocks are `pointerEvents="box-none"`, so the transcript under their
76
+ * empty space still scrolls and takes clicks. The measured heights reach an
77
+ * `AiChatThread` child through context (its content padding) and any other
78
+ * child through `useAiChatChromeInsets()`.
42
79
  */
43
80
  function AiChatContainer({
44
81
  project,
@@ -55,6 +92,7 @@ function AiChatContainer({
55
92
  workingLabel,
56
93
  background,
57
94
  surface = true,
95
+ floatingChrome = false,
58
96
  labels,
59
97
  style,
60
98
  testID
@@ -65,76 +103,104 @@ function AiChatContainer({
65
103
  ...DEFAULT_LABELS,
66
104
  ...labels
67
105
  }), [labels]);
68
- return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
69
- testID: testID,
70
- style: [{
71
- height: '100%',
72
- minWidth: 0,
73
- flex: 1,
74
- flexDirection: 'column',
75
- overflow: 'hidden',
76
- borderRadius: _shared.CONTAINER_RADIUS,
77
- backgroundColor: surface ? palette.secondary : 'transparent'
78
- }, style],
79
- children: [background ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
80
- pointerEvents: "none",
106
+ const hasCrumbRow = title != null || project != null || onShare || onMore || actions != null;
107
+ const crumbRow = hasCrumbRow ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
108
+ pointerEvents: "box-none",
109
+ style: {
110
+ width: '100%',
111
+ flexDirection: 'row',
112
+ alignItems: 'center',
113
+ justifyContent: 'space-between',
114
+ gap: 8,
115
+ paddingLeft: 16,
116
+ paddingRight: 16,
117
+ paddingTop: 16
118
+ },
119
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
120
+ pointerEvents: "box-none",
81
121
  style: {
82
- position: 'absolute',
83
- top: 0,
84
- left: 0,
85
- right: 0,
86
- bottom: 0
122
+ minWidth: 0,
123
+ flex: 1
87
124
  },
88
- children: background
89
- }) : null, header, title != null || project != null || onShare || onMore || actions != null ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
125
+ children: title != null || project != null ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_index2.Breadcrumb, {
126
+ accessibilityLabel: l.breadcrumb,
127
+ children: [project === undefined ? null : /*#__PURE__*/(0, _jsxRuntime.jsx)(_index2.BreadcrumbItem, {
128
+ icon: projectIcon,
129
+ onPress: onProjectPress,
130
+ children: project
131
+ }), title == null ? null : /*#__PURE__*/(0, _jsxRuntime.jsx)(_index2.BreadcrumbItem, {
132
+ current: true,
133
+ children: title
134
+ })]
135
+ }) : null
136
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
90
137
  style: {
91
- width: '100%',
138
+ flexShrink: 0,
92
139
  flexDirection: 'row',
93
140
  alignItems: 'center',
94
- justifyContent: 'space-between',
95
- gap: 8,
96
- paddingLeft: 16,
97
- paddingRight: 16,
98
- paddingTop: 16
141
+ gap: 8
99
142
  },
100
- children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
101
- style: {
102
- minWidth: 0,
103
- flex: 1
104
- },
105
- children: title != null || project != null ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_index2.Breadcrumb, {
106
- accessibilityLabel: l.breadcrumb,
107
- children: [project === undefined ? null : /*#__PURE__*/(0, _jsxRuntime.jsx)(_index2.BreadcrumbItem, {
108
- icon: projectIcon,
109
- onPress: onProjectPress,
110
- children: project
111
- }), title == null ? null : /*#__PURE__*/(0, _jsxRuntime.jsx)(_index2.BreadcrumbItem, {
112
- current: true,
113
- children: title
114
- })]
115
- }) : null
116
- }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
117
- style: {
118
- flexShrink: 0,
119
- flexDirection: 'row',
120
- alignItems: 'center',
121
- gap: 8
122
- },
123
- children: actions ?? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
124
- children: [onShare ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_AiChatControls.GlyphAction, {
125
- icon: _RiShare2Line.RiShare2Line,
126
- label: l.share,
127
- onPress: onShare,
128
- palette: palette
129
- }) : null, onMore ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_AiChatControls.GlyphAction, {
130
- icon: _RiMoreFill.RiMoreFill,
131
- label: l.more,
132
- onPress: onMore,
133
- palette: palette
134
- }) : null]
135
- })
136
- })]
137
- }) : null, children, /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
143
+ children: actions ?? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
144
+ children: [onShare ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_AiChatControls.GlyphAction, {
145
+ icon: _RiShare2Line.RiShare2Line,
146
+ label: l.share,
147
+ onPress: onShare,
148
+ palette: palette
149
+ }) : null, onMore ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_AiChatControls.GlyphAction, {
150
+ icon: _RiMoreFill.RiMoreFill,
151
+ label: l.more,
152
+ onPress: onMore,
153
+ palette: palette
154
+ }) : null]
155
+ })
156
+ })]
157
+ }) : null;
158
+ const footerBody = /*#__PURE__*/(0, _jsxRuntime.jsxs)(_jsxRuntime.Fragment, {
159
+ children: [working ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_index.AgentThinking, {
160
+ variant: "infinity",
161
+ label: workingLabel,
162
+ style: {
163
+ paddingLeft: 6,
164
+ paddingRight: 6
165
+ }
166
+ }) : null, composer]
167
+ });
168
+ const backgroundLayer = background ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
169
+ pointerEvents: "none",
170
+ style: {
171
+ position: 'absolute',
172
+ top: 0,
173
+ left: 0,
174
+ right: 0,
175
+ bottom: 0
176
+ },
177
+ children: background
178
+ }) : null;
179
+ const rootStyle = [{
180
+ height: '100%',
181
+ minWidth: 0,
182
+ flex: 1,
183
+ flexDirection: 'column',
184
+ overflow: 'hidden',
185
+ borderRadius: _shared.CONTAINER_RADIUS,
186
+ backgroundColor: surface ? palette.secondary : 'transparent'
187
+ }, style];
188
+ if (floatingChrome) {
189
+ return /*#__PURE__*/(0, _jsxRuntime.jsx)(FloatingChrome, {
190
+ testID: testID,
191
+ rootStyle: rootStyle,
192
+ backgroundLayer: backgroundLayer,
193
+ header: header,
194
+ crumbRow: crumbRow,
195
+ footerBody: footerBody,
196
+ fadeColor: surface ? palette.secondary : null,
197
+ children: children
198
+ });
199
+ }
200
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
201
+ testID: testID,
202
+ style: rootStyle,
203
+ children: [backgroundLayer, header, crumbRow, children, /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
138
204
  style: {
139
205
  width: '100%',
140
206
  flexDirection: 'column',
@@ -144,14 +210,171 @@ function AiChatContainer({
144
210
  paddingTop: 12,
145
211
  paddingBottom: 10
146
212
  },
147
- children: [working ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_index.AgentThinking, {
148
- variant: "infinity",
149
- label: workingLabel,
213
+ children: footerBody
214
+ })]
215
+ });
216
+ }
217
+ /**
218
+ * `AiChatContainer floatingChrome`: the same parts, the chrome floated over the
219
+ * transcript. Everything it measures lives here, so the stacked path above
220
+ * carries none of the state.
221
+ *
222
+ * The edge opacities are shared values written from the thread's scroll
223
+ * reports, so a scroll frame re-renders nothing; the booleans only mount and
224
+ * unmount the scrims, and change once per crossing. Same model as the
225
+ * sidebar's `SidebarScrollArea`, whose scrim this reuses.
226
+ */
227
+ function FloatingChrome({
228
+ testID,
229
+ rootStyle,
230
+ backgroundLayer,
231
+ header,
232
+ crumbRow,
233
+ footerBody,
234
+ fadeColor,
235
+ children
236
+ }) {
237
+ const [insets, setInsets] = (0, _react.useState)({
238
+ top: 0,
239
+ bottom: 0
240
+ });
241
+ const [edges, setEdges] = (0, _react.useState)({
242
+ top: false,
243
+ bottom: false
244
+ });
245
+ const topFade = (0, _reactNativeReanimated.useSharedValue)(0);
246
+ const bottomFade = (0, _reactNativeReanimated.useSharedValue)(0);
247
+ const onHeaderLayout = (0, _react.useCallback)(event => {
248
+ const top = Math.round(event.nativeEvent.layout.height);
249
+ setInsets(previous => previous.top === top ? previous : {
250
+ ...previous,
251
+ top
252
+ });
253
+ }, []);
254
+ const onFooterLayout = (0, _react.useCallback)(event => {
255
+ const bottom = Math.round(event.nativeEvent.layout.height);
256
+ setInsets(previous => previous.bottom === bottom ? previous : {
257
+ ...previous,
258
+ bottom
259
+ });
260
+ }, []);
261
+ const reportScroll = (0, _react.useCallback)(({
262
+ offset,
263
+ viewport,
264
+ content
265
+ }) => {
266
+ const range = Math.max(0, content - viewport);
267
+ const y = Math.max(0, Math.min(offset, range));
268
+ const overflow = viewport > 0 && range > EDGE_THRESHOLD;
269
+ // The first turn rests THREAD_PAD_TOP below the header and the last one
270
+ // right on the footer's fade strip, so the overlap starts at the first px
271
+ // scrolled and each ramp spans the gap it is closing.
272
+ const top = overflow && y > EDGE_THRESHOLD;
273
+ const bottom = overflow && range - y > EDGE_THRESHOLD;
274
+ topFade.value = overflow ? Math.min(1, y / THREAD_PAD_TOP) : 0;
275
+ bottomFade.value = overflow ? Math.min(1, (range - y) / FOOTER_FADE) : 0;
276
+ setEdges(previous => previous.top === top && previous.bottom === bottom ? previous : {
277
+ top,
278
+ bottom
279
+ });
280
+ }, [topFade, bottomFade]);
281
+ const chrome = (0, _react.useMemo)(() => ({
282
+ insets,
283
+ reportScroll
284
+ }), [insets, reportScroll]);
285
+
286
+ // SVG never receives alpha inside stopColor; it travels on the containing view.
287
+ const parsed = fadeColor ? (0, _colorUtils.parseRgba)(fadeColor) : null;
288
+ const solid = parsed ? `rgb(${parsed.r}, ${parsed.g}, ${parsed.b})` : fadeColor;
289
+ const alpha = parsed?.a ?? 1;
290
+ const topStyle = (0, _reactNativeReanimated.useAnimatedStyle)(() => ({
291
+ opacity: topFade.value * alpha
292
+ }), [topFade, alpha]);
293
+ const bottomStyle = (0, _reactNativeReanimated.useAnimatedStyle)(() => ({
294
+ opacity: bottomFade.value * alpha
295
+ }), [bottomFade, alpha]);
296
+ const prefix = testID ?? 'ai-chat';
297
+ return /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
298
+ testID: testID,
299
+ style: [rootStyle, {
300
+ position: 'relative'
301
+ }],
302
+ children: [backgroundLayer, /*#__PURE__*/(0, _jsxRuntime.jsx)(_context.AiChatFloatingChromeContext.Provider, {
303
+ value: chrome,
304
+ children: children
305
+ }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
306
+ testID: `${prefix}-chrome-top`,
307
+ pointerEvents: "box-none",
308
+ onLayout: onHeaderLayout,
309
+ style: {
310
+ position: 'absolute',
311
+ top: 0,
312
+ left: 0,
313
+ right: 0
314
+ },
315
+ children: [solid && edges.top && insets.top > 0 ? /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNativeReanimated.default.View, {
316
+ testID: `${prefix}-fade-top`,
317
+ pointerEvents: "none",
318
+ ...HIDDEN_FROM_A11Y,
319
+ style: [{
320
+ position: 'absolute',
321
+ top: 0,
322
+ left: 0,
323
+ right: 0,
324
+ // Past the block's own bottom: a ramp that ends where the layout
325
+ // does is a line across the transcript again.
326
+ height: Math.round(insets.top * (1 + _EdgeScrim.SCRIM_TAIL_RATIO))
327
+ }, topStyle],
328
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_EdgeScrim.EdgeScrim, {
329
+ color: solid
330
+ })
331
+ }) : null, header, crumbRow]
332
+ }), /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNative.View, {
333
+ testID: `${prefix}-chrome-bottom`,
334
+ pointerEvents: "box-none",
335
+ onLayout: onFooterLayout,
336
+ style: {
337
+ position: 'absolute',
338
+ left: 0,
339
+ right: 0,
340
+ bottom: 0
341
+ },
342
+ children: [solid && edges.bottom ? /*#__PURE__*/(0, _jsxRuntime.jsxs)(_reactNativeReanimated.default.View, {
343
+ testID: `${prefix}-fade-bottom`,
344
+ pointerEvents: "none",
345
+ ...HIDDEN_FROM_A11Y,
346
+ style: [_reactNative.StyleSheet.absoluteFill, {
347
+ flexDirection: 'column'
348
+ }, bottomStyle],
349
+ children: [/*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
350
+ style: {
351
+ height: FOOTER_FADE,
352
+ transform: [{
353
+ rotate: '180deg'
354
+ }]
355
+ },
356
+ children: /*#__PURE__*/(0, _jsxRuntime.jsx)(_EdgeScrim.EdgeScrim, {
357
+ color: solid
358
+ })
359
+ }), /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
360
+ style: {
361
+ flex: 1,
362
+ backgroundColor: solid
363
+ }
364
+ })]
365
+ }) : null, /*#__PURE__*/(0, _jsxRuntime.jsx)(_reactNative.View, {
366
+ pointerEvents: "box-none",
150
367
  style: {
151
- paddingLeft: 6,
152
- paddingRight: 6
153
- }
154
- }) : null, composer]
368
+ width: '100%',
369
+ flexDirection: 'column',
370
+ gap: 10,
371
+ paddingLeft: 10,
372
+ paddingRight: 10,
373
+ paddingTop: FOOTER_FADE,
374
+ paddingBottom: 10
375
+ },
376
+ children: footerBody
377
+ })]
155
378
  })]
156
379
  });
157
380
  }
@@ -177,6 +400,13 @@ function AiChatContainer({
177
400
  *
178
401
  * There is no virtualization and no `scrollToIndex`: a host jumping to a turn
179
402
  * measures its row (`onLayout`) and calls `scrollToOffset`.
403
+ *
404
+ * Inside `AiChatContainer floatingChrome` the thread still fills its parent —
405
+ * the whole card — and pads its CONTENT by the chrome the container measured:
406
+ * pt = header block + 16, pb = footer block. At rest the first and last turns
407
+ * sit exactly where the stacked layout puts them relative to the chrome; in
408
+ * motion they pass under it. It also reports its position so the container
409
+ * knows when to fade each edge. Anywhere else nothing of this exists.
180
410
  */
181
411
  const AiChatThread = exports.AiChatThread = /*#__PURE__*/(0, _react.forwardRef)(function AiChatThread({
182
412
  children,
@@ -191,14 +421,45 @@ const AiChatThread = exports.AiChatThread = /*#__PURE__*/(0, _react.forwardRef)(
191
421
  style,
192
422
  testID
193
423
  }, ref) {
424
+ const chrome = (0, _react.useContext)(_context.AiChatFloatingChromeContext);
194
425
  const scrollRef = (0, _react.useRef)(null);
195
426
  const lastHeight = (0, _react.useRef)(0);
427
+ /**
428
+ * The latest content height from ANY source, for the chrome report only.
429
+ * `lastHeight` must change solely in `onContentSizeChange`: a scroll event
430
+ * carrying the new size first would otherwise hide the growth from the follow.
431
+ */
432
+ const contentHeight = (0, _react.useRef)(0);
196
433
  const offset = (0, _react.useRef)(0);
197
434
  const viewport = (0, _react.useRef)(0);
198
435
  /** An `onStartReached` is out and its page has not landed yet. */
199
436
  const pageOut = (0, _react.useRef)(false);
200
437
  /** The reader has left the top zone, so the next approach may fire again. */
201
438
  const startArmed = (0, _react.useRef)(true);
439
+
440
+ // The chrome's padding changing is not a new turn: it is recorded here,
441
+ // before the resize it causes, so `onContentSizeChange` can tell the two apart.
442
+ const insetTop = chrome?.insets.top ?? 0;
443
+ const insetBottom = chrome?.insets.bottom ?? 0;
444
+ const appliedInsets = (0, _react.useRef)({
445
+ top: insetTop,
446
+ bottom: insetBottom
447
+ });
448
+ const insetShift = (0, _react.useRef)({
449
+ top: 0,
450
+ bottom: 0
451
+ });
452
+ (0, _useIsomorphicLayoutEffect.useIsomorphicLayoutEffect)(() => {
453
+ const applied = appliedInsets.current;
454
+ insetShift.current = {
455
+ top: insetShift.current.top + insetTop - applied.top,
456
+ bottom: insetShift.current.bottom + insetBottom - applied.bottom
457
+ };
458
+ appliedInsets.current = {
459
+ top: insetTop,
460
+ bottom: insetBottom
461
+ };
462
+ }, [insetTop, insetBottom]);
202
463
  (0, _react.useImperativeHandle)(ref, () => ({
203
464
  scrollToEnd: options => scrollRef.current?.scrollToEnd({
204
465
  animated: options?.animated ?? true
@@ -212,14 +473,24 @@ const AiChatThread = exports.AiChatThread = /*#__PURE__*/(0, _react.forwardRef)(
212
473
  }),
213
474
  getScrollView: () => scrollRef.current
214
475
  }), []);
215
- const listening = !!onScroll || !!onStartReached || followThreshold !== undefined || maintainStartPosition;
476
+ const listening = !!chrome || !!onScroll || !!onStartReached || followThreshold !== undefined || maintainStartPosition;
477
+ const report = (0, _react.useCallback)(() => {
478
+ chrome?.reportScroll({
479
+ offset: offset.current,
480
+ viewport: viewport.current,
481
+ content: contentHeight.current
482
+ });
483
+ }, [chrome]);
216
484
  const handleScroll = (0, _react.useCallback)(event => {
217
485
  const {
218
486
  contentOffset,
219
- layoutMeasurement
487
+ layoutMeasurement,
488
+ contentSize
220
489
  } = event.nativeEvent;
221
490
  offset.current = contentOffset.y;
222
491
  viewport.current = layoutMeasurement.height;
492
+ if (contentSize?.height) contentHeight.current = contentSize.height;
493
+ report();
223
494
  if (onStartReached) {
224
495
  if (contentOffset.y <= onStartReachedThreshold) {
225
496
  if (startArmed.current) {
@@ -232,16 +503,45 @@ const AiChatThread = exports.AiChatThread = /*#__PURE__*/(0, _react.forwardRef)(
232
503
  }
233
504
  }
234
505
  onScroll?.(event);
235
- }, [onScroll, onStartReached, onStartReachedThreshold]);
506
+ }, [onScroll, onStartReached, onStartReachedThreshold, report]);
236
507
  const onLayout = (0, _react.useCallback)(event => {
237
508
  viewport.current = event.nativeEvent.layout.height;
238
- }, []);
509
+ report();
510
+ }, [report]);
239
511
  const onContentSizeChange = (0, _react.useCallback)((_width, height) => {
240
512
  const previous = lastHeight.current;
241
513
  const grew = height > previous + 0.5;
242
514
  const first = previous === 0;
243
515
  lastHeight.current = height;
244
- if (!grew || first) return;
516
+ contentHeight.current = height;
517
+ const shift = insetShift.current;
518
+ insetShift.current = {
519
+ top: 0,
520
+ bottom: 0
521
+ };
522
+ report();
523
+ if (first) return;
524
+
525
+ // Exactly the chrome's padding changed (a composer growing, the header
526
+ // measuring in): hold the reader still instead of following. At the end
527
+ // they stay at the end, unanimated; mid-thread, a top shift is absorbed
528
+ // into the offset so the turn they are on does not move.
529
+ const chromeShift = shift.top + shift.bottom;
530
+ if (chromeShift !== 0 && Math.abs(height - previous - chromeShift) < 1) {
531
+ const fromBottom = previous - offset.current - viewport.current;
532
+ if (fromBottom <= EDGE_THRESHOLD) {
533
+ scrollRef.current?.scrollToEnd({
534
+ animated: false
535
+ });
536
+ } else if (offset.current > 0 && shift.top !== 0) {
537
+ scrollRef.current?.scrollTo({
538
+ y: offset.current + shift.top,
539
+ animated: false
540
+ });
541
+ }
542
+ return;
543
+ }
544
+ if (!grew) return;
245
545
 
246
546
  // A page landing above the reader: keep the turn they are on where it is.
247
547
  if (maintainStartPosition && pageOut.current) {
@@ -292,8 +592,15 @@ const AiChatThread = exports.AiChatThread = /*#__PURE__*/(0, _react.forwardRef)(
292
592
  gap: 12,
293
593
  paddingLeft: 16,
294
594
  paddingRight: 16,
295
- paddingTop: 16
595
+ paddingTop: THREAD_PAD_TOP + (chrome ? chrome.insets.top : 0),
596
+ ...(chrome ? {
597
+ paddingBottom: chrome.insets.bottom
598
+ } : null)
296
599
  },
600
+ scrollIndicatorInsets: chrome ? {
601
+ top: chrome.insets.top,
602
+ bottom: chrome.insets.bottom
603
+ } : undefined,
297
604
  children: children
298
605
  });
299
606
  });