@oxy.so/bloom 3.2.1 → 3.3.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.
- package/docs/adoption-matrix.mdx +2 -2
- package/docs/ai-chat.mdx +31 -3
- package/docs/composer-panel.mdx +23 -6
- package/lib/commonjs/ai-chat/AiChatContainer.js +122 -14
- package/lib/commonjs/ai-chat/AiChatContainer.js.map +1 -1
- package/lib/commonjs/ai-chat/AiChatShell.js +46 -6
- package/lib/commonjs/ai-chat/AiChatShell.js.map +1 -1
- package/lib/commonjs/ai-chat/context.js +5 -1
- package/lib/commonjs/ai-chat/context.js.map +1 -1
- package/lib/commonjs/ai-chat/index.js +7 -0
- package/lib/commonjs/ai-chat/index.js.map +1 -1
- package/lib/commonjs/ai-chat/index.web.js +7 -0
- package/lib/commonjs/ai-chat/index.web.js.map +1 -1
- package/lib/commonjs/composer-panel/ComposerControls.js +61 -0
- package/lib/commonjs/composer-panel/ComposerControls.js.map +1 -1
- package/lib/commonjs/composer-panel/ComposerPanelBase.js +12 -4
- package/lib/commonjs/composer-panel/ComposerPanelBase.js.map +1 -1
- package/lib/commonjs/composer-panel/ComposerPillBase.js +114 -79
- package/lib/commonjs/composer-panel/ComposerPillBase.js.map +1 -1
- package/lib/commonjs/composer-panel/EffortSlider.js +24 -15
- package/lib/commonjs/composer-panel/EffortSlider.js.map +1 -1
- package/lib/commonjs/composer-panel/ModelPickerBase.js +15 -4
- package/lib/commonjs/composer-panel/ModelPickerBase.js.map +1 -1
- package/lib/module/ai-chat/AiChatContainer.js +122 -14
- package/lib/module/ai-chat/AiChatContainer.js.map +1 -1
- package/lib/module/ai-chat/AiChatShell.js +46 -6
- package/lib/module/ai-chat/AiChatShell.js.map +1 -1
- package/lib/module/ai-chat/context.js +5 -1
- package/lib/module/ai-chat/context.js.map +1 -1
- package/lib/module/ai-chat/index.js +1 -0
- package/lib/module/ai-chat/index.js.map +1 -1
- package/lib/module/ai-chat/index.web.js +1 -0
- package/lib/module/ai-chat/index.web.js.map +1 -1
- package/lib/module/composer-panel/ComposerControls.js +60 -0
- package/lib/module/composer-panel/ComposerControls.js.map +1 -1
- package/lib/module/composer-panel/ComposerPanelBase.js +13 -5
- package/lib/module/composer-panel/ComposerPanelBase.js.map +1 -1
- package/lib/module/composer-panel/ComposerPillBase.js +115 -80
- package/lib/module/composer-panel/ComposerPillBase.js.map +1 -1
- package/lib/module/composer-panel/EffortSlider.js +24 -15
- package/lib/module/composer-panel/EffortSlider.js.map +1 -1
- package/lib/module/composer-panel/ModelPickerBase.js +15 -4
- package/lib/module/composer-panel/ModelPickerBase.js.map +1 -1
- package/lib/typescript/commonjs/ai-chat/AiChatContainer.d.ts +28 -5
- package/lib/typescript/commonjs/ai-chat/AiChatContainer.d.ts.map +1 -1
- package/lib/typescript/commonjs/ai-chat/AiChatShell.d.ts +11 -2
- package/lib/typescript/commonjs/ai-chat/AiChatShell.d.ts.map +1 -1
- package/lib/typescript/commonjs/ai-chat/context.d.ts +19 -1
- package/lib/typescript/commonjs/ai-chat/context.d.ts.map +1 -1
- package/lib/typescript/commonjs/ai-chat/index.d.ts +3 -1
- package/lib/typescript/commonjs/ai-chat/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/ai-chat/index.web.d.ts +3 -1
- package/lib/typescript/commonjs/ai-chat/index.web.d.ts.map +1 -1
- package/lib/typescript/commonjs/ai-chat/types.d.ts +101 -3
- package/lib/typescript/commonjs/ai-chat/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/composer-panel/ComposerControls.d.ts +14 -0
- package/lib/typescript/commonjs/composer-panel/ComposerControls.d.ts.map +1 -1
- package/lib/typescript/commonjs/composer-panel/ComposerPanelBase.d.ts +3 -2
- package/lib/typescript/commonjs/composer-panel/ComposerPanelBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/composer-panel/ComposerPillBase.d.ts +3 -2
- package/lib/typescript/commonjs/composer-panel/ComposerPillBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/composer-panel/EffortSlider.d.ts +10 -2
- package/lib/typescript/commonjs/composer-panel/EffortSlider.d.ts.map +1 -1
- package/lib/typescript/commonjs/composer-panel/ModelPickerBase.d.ts.map +1 -1
- package/lib/typescript/commonjs/composer-panel/types.d.ts +61 -20
- package/lib/typescript/commonjs/composer-panel/types.d.ts.map +1 -1
- package/lib/typescript/module/ai-chat/AiChatContainer.d.ts +28 -5
- package/lib/typescript/module/ai-chat/AiChatContainer.d.ts.map +1 -1
- package/lib/typescript/module/ai-chat/AiChatShell.d.ts +11 -2
- package/lib/typescript/module/ai-chat/AiChatShell.d.ts.map +1 -1
- package/lib/typescript/module/ai-chat/context.d.ts +19 -1
- package/lib/typescript/module/ai-chat/context.d.ts.map +1 -1
- package/lib/typescript/module/ai-chat/index.d.ts +3 -1
- package/lib/typescript/module/ai-chat/index.d.ts.map +1 -1
- package/lib/typescript/module/ai-chat/index.web.d.ts +3 -1
- package/lib/typescript/module/ai-chat/index.web.d.ts.map +1 -1
- package/lib/typescript/module/ai-chat/types.d.ts +101 -3
- package/lib/typescript/module/ai-chat/types.d.ts.map +1 -1
- package/lib/typescript/module/composer-panel/ComposerControls.d.ts +14 -0
- package/lib/typescript/module/composer-panel/ComposerControls.d.ts.map +1 -1
- package/lib/typescript/module/composer-panel/ComposerPanelBase.d.ts +3 -2
- package/lib/typescript/module/composer-panel/ComposerPanelBase.d.ts.map +1 -1
- package/lib/typescript/module/composer-panel/ComposerPillBase.d.ts +3 -2
- package/lib/typescript/module/composer-panel/ComposerPillBase.d.ts.map +1 -1
- package/lib/typescript/module/composer-panel/EffortSlider.d.ts +10 -2
- package/lib/typescript/module/composer-panel/EffortSlider.d.ts.map +1 -1
- package/lib/typescript/module/composer-panel/ModelPickerBase.d.ts.map +1 -1
- package/lib/typescript/module/composer-panel/types.d.ts +61 -20
- package/lib/typescript/module/composer-panel/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/support/adoption-matrix.ts +664 -0
- package/src/__tests__/support/collision-fixture-barrel.ts +20 -0
- package/src/__tests__/support/composite.ts +68 -0
- package/src/__tests__/support/constructed-style-sheets.ts +68 -0
- package/src/__tests__/support/press-host.ts +30 -0
- package/src/__tests__/support/rendered-style.ts +99 -0
- package/src/__tests__/support/unread-hook-fixture.ts +33 -0
- package/src/ai-chat/AiChatContainer.tsx +137 -16
- package/src/ai-chat/AiChatShell.tsx +59 -5
- package/src/ai-chat/context.ts +19 -1
- package/src/ai-chat/index.ts +3 -0
- package/src/ai-chat/index.web.ts +3 -0
- package/src/ai-chat/types.ts +104 -3
- package/src/composer-panel/ComposerControls.tsx +54 -0
- package/src/composer-panel/ComposerPanelBase.tsx +13 -5
- package/src/composer-panel/ComposerPillBase.tsx +101 -59
- package/src/composer-panel/EffortSlider.tsx +38 -18
- package/src/composer-panel/ModelPickerBase.tsx +23 -7
- package/src/composer-panel/types.ts +61 -20
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +7682 -0
- package/src/theme/__tests__/fixtures/color-engine-golden.json +1 -0
package/src/ai-chat/context.ts
CHANGED
|
@@ -35,7 +35,11 @@ export function useAiChatPlatform(): AiChatPlatform {
|
|
|
35
35
|
return platform;
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* What `AiChatShell` publishes: read it with `useAiChatShell()`. The chat
|
|
40
|
+
* container's mobile header runs on it, and so does anything a host needs to
|
|
41
|
+
* know about the frame around its own nav.
|
|
42
|
+
*/
|
|
39
43
|
export interface AiChatShellState {
|
|
40
44
|
/** Below `xl`: the panel is a drawer and the mobile header shows. */
|
|
41
45
|
compact: boolean;
|
|
@@ -43,7 +47,21 @@ export interface AiChatShellState {
|
|
|
43
47
|
navCollapsed: boolean;
|
|
44
48
|
hasNav: boolean;
|
|
45
49
|
hasPanel: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Whether the nav is on screen at all: `true` from `lg` up (in flow) and,
|
|
52
|
+
* below it, only while the drawer is open.
|
|
53
|
+
*
|
|
54
|
+
* A closed drawer stays MOUNTED — it is translated offscreen, not removed —
|
|
55
|
+
* so a nav rendered into `mobileSidebar` is still focusable and still read by
|
|
56
|
+
* a screen reader unless its own tree says otherwise. This is the signal to
|
|
57
|
+
* hide it by: skip the tab order and mark the subtree hidden while it is
|
|
58
|
+
* `false`.
|
|
59
|
+
*/
|
|
60
|
+
navPresented: boolean;
|
|
61
|
+
/** The in-flow sidebar is narrowed to its rail width (`sidebarCollapsed`). */
|
|
62
|
+
sidebarCollapsed: boolean;
|
|
46
63
|
openNav: () => void;
|
|
64
|
+
closeNav: () => void;
|
|
47
65
|
openPanel: () => void;
|
|
48
66
|
panelLabel: string;
|
|
49
67
|
panelIcon: AiChatShellIcon;
|
package/src/ai-chat/index.ts
CHANGED
|
@@ -15,6 +15,8 @@ export {
|
|
|
15
15
|
AiChatUserMessage,
|
|
16
16
|
} from './AiChatMessages';
|
|
17
17
|
export { AiChatMobileHeader, AiChatResizeHandle, AiChatShell } from './AiChatShell';
|
|
18
|
+
export { useAiChatShell } from './context';
|
|
19
|
+
export type { AiChatShellState } from './context';
|
|
18
20
|
export type {
|
|
19
21
|
AiChatAssistantMessageProps,
|
|
20
22
|
AiChatBulletListProps,
|
|
@@ -41,6 +43,7 @@ export type {
|
|
|
41
43
|
AiChatShellLabels,
|
|
42
44
|
AiChatShellProps,
|
|
43
45
|
AiChatStrongProps,
|
|
46
|
+
AiChatThreadHandle,
|
|
44
47
|
AiChatThreadProps,
|
|
45
48
|
AiChatUserMessageProps,
|
|
46
49
|
} from './types';
|
package/src/ai-chat/index.web.ts
CHANGED
|
@@ -17,6 +17,8 @@ export {
|
|
|
17
17
|
AiChatUserMessage,
|
|
18
18
|
} from './AiChatMessages';
|
|
19
19
|
export { AiChatMobileHeader, AiChatResizeHandle, AiChatShell } from './AiChatShell';
|
|
20
|
+
export { useAiChatShell } from './context';
|
|
21
|
+
export type { AiChatShellState } from './context';
|
|
20
22
|
export type {
|
|
21
23
|
AiChatAssistantMessageProps,
|
|
22
24
|
AiChatBulletListProps,
|
|
@@ -43,6 +45,7 @@ export type {
|
|
|
43
45
|
AiChatShellLabels,
|
|
44
46
|
AiChatShellProps,
|
|
45
47
|
AiChatStrongProps,
|
|
48
|
+
AiChatThreadHandle,
|
|
46
49
|
AiChatThreadProps,
|
|
47
50
|
AiChatUserMessageProps,
|
|
48
51
|
} from './types';
|
package/src/ai-chat/types.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import type { ComponentType, ReactNode } from 'react';
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
ImageSourcePropType,
|
|
4
|
+
NativeScrollEvent,
|
|
5
|
+
NativeSyntheticEvent,
|
|
6
|
+
ScrollView,
|
|
7
|
+
StyleProp,
|
|
8
|
+
ViewStyle,
|
|
9
|
+
} from 'react-native';
|
|
3
10
|
|
|
4
11
|
import type { CodeLanguage } from '../code';
|
|
5
12
|
import type { BloomIconComponent } from '../icons/icon-component';
|
|
@@ -270,8 +277,12 @@ export interface AiChatContainerLabels {
|
|
|
270
277
|
}
|
|
271
278
|
|
|
272
279
|
export interface AiChatContainerProps {
|
|
273
|
-
/**
|
|
274
|
-
|
|
280
|
+
/**
|
|
281
|
+
* The project crumb, after its folder glyph. Omit it for a chat that belongs
|
|
282
|
+
* to no project: the breadcrumb is then the title alone, with no empty crumb
|
|
283
|
+
* and no folder glyph.
|
|
284
|
+
*/
|
|
285
|
+
project?: string;
|
|
275
286
|
/** The current chat crumb. */
|
|
276
287
|
title: string;
|
|
277
288
|
/** The project crumb's glyph. Default `RiFolderLine`. */
|
|
@@ -289,14 +300,78 @@ export interface AiChatContainerProps {
|
|
|
289
300
|
working?: boolean;
|
|
290
301
|
/** `AgentThinking`'s label. Default `'Thinking'`. */
|
|
291
302
|
workingLabel?: string;
|
|
303
|
+
/**
|
|
304
|
+
* A layer filling the container, drawn ABOVE its own surface and BELOW every
|
|
305
|
+
* turn, header and composer — a host's wallpaper, gradient or animated field.
|
|
306
|
+
* It is clipped to the container's radius and never interactive
|
|
307
|
+
* (`pointerEvents: 'none'`), so an opaque node needs nothing else.
|
|
308
|
+
*
|
|
309
|
+
* Set `surface={false}` beside it when the node is translucent and should
|
|
310
|
+
* show what the container is sitting on rather than background-secondary.
|
|
311
|
+
*/
|
|
312
|
+
background?: ReactNode;
|
|
313
|
+
/**
|
|
314
|
+
* Paint the container's own background-secondary surface. Default `true`;
|
|
315
|
+
* `false` leaves the root transparent.
|
|
316
|
+
*/
|
|
317
|
+
surface?: boolean;
|
|
292
318
|
labels?: AiChatContainerLabels;
|
|
293
319
|
style?: StyleProp<ViewStyle>;
|
|
294
320
|
testID?: string;
|
|
295
321
|
}
|
|
296
322
|
|
|
323
|
+
/**
|
|
324
|
+
* What a `ref` on `AiChatThread` hands back: enough to drive the scroll a host
|
|
325
|
+
* cannot drive from props — a cursor jump, a restore, a follow of its own.
|
|
326
|
+
*/
|
|
327
|
+
export interface AiChatThreadHandle {
|
|
328
|
+
/** Jump to the newest turn. `animated` defaults to `true`. */
|
|
329
|
+
scrollToEnd: (options?: { animated?: boolean }) => void;
|
|
330
|
+
/** Scroll to an absolute offset, px from the top. `animated` defaults to `false`. */
|
|
331
|
+
scrollToOffset: (options: { offset: number; animated?: boolean }) => void;
|
|
332
|
+
/** The `ScrollView` itself, for anything this handle does not cover. */
|
|
333
|
+
getScrollView: () => ScrollView | null;
|
|
334
|
+
}
|
|
335
|
+
|
|
297
336
|
export interface AiChatThreadProps {
|
|
298
337
|
/** The turns. The thread follows the newest one with a smooth scroll as it grows. */
|
|
299
338
|
children: ReactNode;
|
|
339
|
+
/**
|
|
340
|
+
* Follow the newest turn as the content grows. Default `true` — the thread's
|
|
341
|
+
* behaviour since it shipped. `false` leaves every scroll to the host (and to
|
|
342
|
+
* the `ref`).
|
|
343
|
+
*/
|
|
344
|
+
autoFollow?: boolean;
|
|
345
|
+
/** Animate the follow. Default `true`; `false` for a restore that must not be seen travelling. */
|
|
346
|
+
followAnimated?: boolean;
|
|
347
|
+
/**
|
|
348
|
+
* Follow only while the reader is within this many px of the bottom, measured
|
|
349
|
+
* before the growth. Omit to follow every growth (the default), which is what
|
|
350
|
+
* the thread has always done.
|
|
351
|
+
*/
|
|
352
|
+
followThreshold?: number;
|
|
353
|
+
/**
|
|
354
|
+
* The reader came within `onStartReachedThreshold` of the top — a chat pages
|
|
355
|
+
* UPWARD, so this is its pagination signal, not `onEndReached`. It fires once
|
|
356
|
+
* per approach and re-arms when the reader leaves the zone.
|
|
357
|
+
*/
|
|
358
|
+
onStartReached?: () => void;
|
|
359
|
+
/** Distance from the top that fires `onStartReached`, px. Default `300`. */
|
|
360
|
+
onStartReachedThreshold?: number;
|
|
361
|
+
/**
|
|
362
|
+
* Keep the reader on the same turn while a page lands ABOVE them: the growth
|
|
363
|
+
* that follows an `onStartReached` is added to the offset instead of moving
|
|
364
|
+
* the content under the reader. Default `false`, and inert without
|
|
365
|
+
* `onStartReached` — the thread only anchors growth it asked for.
|
|
366
|
+
*
|
|
367
|
+
* This is the web-safe half of `maintainVisibleContentPosition`, which
|
|
368
|
+
* react-native-web ignores.
|
|
369
|
+
*/
|
|
370
|
+
maintainStartPosition?: boolean;
|
|
371
|
+
/** The raw scroll event, for a host tracking its own position. */
|
|
372
|
+
onScroll?: (event: NativeSyntheticEvent<NativeScrollEvent>) => void;
|
|
373
|
+
/** Default `16`. Only applied when something is listening to the scroll. */
|
|
374
|
+
scrollEventThrottle?: number;
|
|
300
375
|
style?: StyleProp<ViewStyle>;
|
|
301
376
|
testID?: string;
|
|
302
377
|
}
|
|
@@ -317,6 +392,15 @@ export interface AiChatShellLabels {
|
|
|
317
392
|
export interface AiChatShellProps {
|
|
318
393
|
/** The floating sidebar shown from `lg` (1024). */
|
|
319
394
|
sidebar: ReactNode;
|
|
395
|
+
/**
|
|
396
|
+
* The in-flow sidebar's column width from `lg` up. Omit and the sidebar sizes
|
|
397
|
+
* itself, as it always has.
|
|
398
|
+
*/
|
|
399
|
+
sidebarWidth?: number;
|
|
400
|
+
/** Narrow the in-flow sidebar to `collapsedSidebarWidth` — a host's icon rail. Default `false`. */
|
|
401
|
+
sidebarCollapsed?: boolean;
|
|
402
|
+
/** The collapsed column's width. Default `56`. */
|
|
403
|
+
collapsedSidebarWidth?: number;
|
|
320
404
|
/** The flat sidebar revealed under the workspace below `lg`. Default: none (no nav drawer). */
|
|
321
405
|
mobileSidebar?: ReactNode;
|
|
322
406
|
/** The chat container. */
|
|
@@ -337,6 +421,23 @@ export interface AiChatShellProps {
|
|
|
337
421
|
/** Controlled panel drawer (below `xl`). */
|
|
338
422
|
panelOpen?: boolean;
|
|
339
423
|
onPanelOpenChange?: (open: boolean) => void;
|
|
424
|
+
/**
|
|
425
|
+
* A layer filling the shell, drawn ABOVE its own background-full and BELOW
|
|
426
|
+
* the sidebar, workspace and drawers. Not interactive
|
|
427
|
+
* (`pointerEvents: 'none'`).
|
|
428
|
+
*
|
|
429
|
+
* The workspace paints background-full of its own, so a shell background is
|
|
430
|
+
* covered wherever the chat sits: pass `surface={false}` with it, which drops
|
|
431
|
+
* the paint on both, and let the chat's own `AiChatContainer` decide what it
|
|
432
|
+
* paints. A `background` on the container is the closer slot when the layer
|
|
433
|
+
* belongs to the chat rather than to the page.
|
|
434
|
+
*/
|
|
435
|
+
background?: ReactNode;
|
|
436
|
+
/**
|
|
437
|
+
* Paint the shell's own background-full, on its root and on the workspace.
|
|
438
|
+
* Default `true`; `false` leaves both transparent.
|
|
439
|
+
*/
|
|
440
|
+
surface?: boolean;
|
|
340
441
|
labels?: AiChatShellLabels;
|
|
341
442
|
style?: StyleProp<ViewStyle>;
|
|
342
443
|
testID?: string;
|
|
@@ -16,6 +16,7 @@ import { paintToCssImage, type ButtonStatePaint } from '../button/shared';
|
|
|
16
16
|
import { DISABLED_OPACITY } from '../styles/tokens';
|
|
17
17
|
import { RiArrowUpLine } from '../icons/remix/RiArrowUpLine';
|
|
18
18
|
import { RiMic2Line } from '../icons/remix/RiMic2Line';
|
|
19
|
+
import { RiStopFill } from '../icons/remix/RiStopFill';
|
|
19
20
|
import type { WebCssStyle } from '../styles/web-view-style';
|
|
20
21
|
import { CONTROL_SIZE, TRANSITION_MS, type ComposerPalette } from './shared';
|
|
21
22
|
import { dataHook, IS_WEB } from './web-hooks';
|
|
@@ -239,3 +240,56 @@ export function SendButton({
|
|
|
239
240
|
</Pressable>
|
|
240
241
|
);
|
|
241
242
|
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Stop — send's other state, drawn on the same disc so the action sits where the
|
|
246
|
+
* reader last pressed: the accent gradient (hover and active crossfading like
|
|
247
|
+
* send's) with a white 20px stop mark.
|
|
248
|
+
*
|
|
249
|
+
* It takes no `disabled`. The composer's `disabled` locks the draft for the
|
|
250
|
+
* whole turn, and the one thing that must stay possible for that whole turn is
|
|
251
|
+
* cancelling it.
|
|
252
|
+
*/
|
|
253
|
+
export function StopButton({
|
|
254
|
+
onPress,
|
|
255
|
+
label,
|
|
256
|
+
palette,
|
|
257
|
+
}: {
|
|
258
|
+
onPress?: () => void;
|
|
259
|
+
label: string;
|
|
260
|
+
palette: ComposerPalette;
|
|
261
|
+
}) {
|
|
262
|
+
const [hovered, setHovered] = useState(false);
|
|
263
|
+
const [pressed, setPressed] = useState(false);
|
|
264
|
+
const paint = palette.send;
|
|
265
|
+
const base = pressed ? paint.active : paint.rest;
|
|
266
|
+
const style: WebCssStyle = {
|
|
267
|
+
position: 'relative',
|
|
268
|
+
width: CONTROL_SIZE,
|
|
269
|
+
height: CONTROL_SIZE,
|
|
270
|
+
alignItems: 'center',
|
|
271
|
+
justifyContent: 'center',
|
|
272
|
+
borderRadius: 9999,
|
|
273
|
+
cursor: 'pointer',
|
|
274
|
+
'--bloom-composer-ring': palette.focusRing,
|
|
275
|
+
};
|
|
276
|
+
return (
|
|
277
|
+
<Pressable
|
|
278
|
+
{...dataHook('bloomComposerControl', 'stop')}
|
|
279
|
+
accessibilityRole="button"
|
|
280
|
+
accessibilityLabel={label}
|
|
281
|
+
onPress={onPress}
|
|
282
|
+
onPressIn={() => setPressed(true)}
|
|
283
|
+
onPressOut={() => setPressed(false)}
|
|
284
|
+
onHoverIn={() => setHovered(true)}
|
|
285
|
+
onHoverOut={() => setHovered(false)}
|
|
286
|
+
style={style}>
|
|
287
|
+
<GradientFill paint={base} />
|
|
288
|
+
<GradientFill paint={paint.hover} opacity={hovered && !pressed ? 1 : 0} />
|
|
289
|
+
{/* Its own positioned box, so it paints above the absolutely placed fills. */}
|
|
290
|
+
<View pointerEvents="none" style={{ position: 'relative', width: 20, height: 20 }}>
|
|
291
|
+
<RiStopFill width={20} height={20} fill="#ffffff" />
|
|
292
|
+
</View>
|
|
293
|
+
</Pressable>
|
|
294
|
+
);
|
|
295
|
+
}
|
|
@@ -22,7 +22,7 @@ import { useTheme } from '../theme/use-theme';
|
|
|
22
22
|
import { TYPE_SCALE } from '../typography';
|
|
23
23
|
import { AddMenu } from './AddMenu';
|
|
24
24
|
import { AttachmentStrip } from './AttachmentStrip';
|
|
25
|
-
import { MicButton, SendButton } from './ComposerControls';
|
|
25
|
+
import { MicButton, SendButton, StopButton } from './ComposerControls';
|
|
26
26
|
import { ModelPickerBase } from './ModelPickerBase';
|
|
27
27
|
import { PermissionMenu } from './PermissionMenu';
|
|
28
28
|
import {
|
|
@@ -48,6 +48,7 @@ const DEFAULT_LABELS: Required<ComposerPanelLabels> = {
|
|
|
48
48
|
learnMore: 'Learn more',
|
|
49
49
|
voice: 'Voice input',
|
|
50
50
|
send: 'Send message',
|
|
51
|
+
stop: 'Stop generating',
|
|
51
52
|
remove: 'Remove',
|
|
52
53
|
};
|
|
53
54
|
|
|
@@ -107,7 +108,8 @@ function Collapse({ open, children }: { open: boolean; children: React.ReactNode
|
|
|
107
108
|
* body pt 6, prompt → controls 20
|
|
108
109
|
* prompt px 6, body-regular, grows 20px a line up to 200 then scrolls
|
|
109
110
|
* controls left: add + permission (8 apart); right: model picker, 16,
|
|
110
|
-
* then mic + send (8 apart)
|
|
111
|
+
* then mic + send (8 apart); `busy` + `onStop` puts the stop
|
|
112
|
+
* control where send was, outside `disabled`'s reach
|
|
111
113
|
*
|
|
112
114
|
* Takes its panel implementation from the family's platform binding.
|
|
113
115
|
*/
|
|
@@ -116,6 +118,8 @@ export function ComposerPanelBase({
|
|
|
116
118
|
defaultValue = '',
|
|
117
119
|
onValueChange,
|
|
118
120
|
onSubmit,
|
|
121
|
+
onStop,
|
|
122
|
+
busy = false,
|
|
119
123
|
disabled = false,
|
|
120
124
|
placeholder = 'Hi, what do you need today?',
|
|
121
125
|
permissions = COMPOSER_PANEL_PERMISSIONS,
|
|
@@ -188,10 +192,10 @@ export function ComposerPanelBase({
|
|
|
188
192
|
);
|
|
189
193
|
|
|
190
194
|
const submit = useCallback(() => {
|
|
191
|
-
if (disabled) return;
|
|
195
|
+
if (disabled || busy) return;
|
|
192
196
|
onSubmit?.(text);
|
|
193
197
|
if (value === undefined) setText('');
|
|
194
|
-
}, [disabled, onSubmit, text, value, setText]);
|
|
198
|
+
}, [disabled, busy, onSubmit, text, value, setText]);
|
|
195
199
|
|
|
196
200
|
const onKeyPress = useCallback(
|
|
197
201
|
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
|
@@ -310,7 +314,11 @@ export function ComposerPanelBase({
|
|
|
310
314
|
label={labels.voice}
|
|
311
315
|
palette={palette}
|
|
312
316
|
/>
|
|
313
|
-
|
|
317
|
+
{busy && onStop ? (
|
|
318
|
+
<StopButton onPress={onStop} label={labels.stop} palette={palette} />
|
|
319
|
+
) : (
|
|
320
|
+
<SendButton disabled={disabled} onPress={submit} label={labels.send} palette={palette} />
|
|
321
|
+
)}
|
|
314
322
|
</View>
|
|
315
323
|
</View>
|
|
316
324
|
</View>
|
|
@@ -23,7 +23,7 @@ import type { WebCssStyle } from '../styles/web-view-style';
|
|
|
23
23
|
import { useTheme } from '../theme/use-theme';
|
|
24
24
|
import { Text, TYPE_SCALE } from '../typography';
|
|
25
25
|
import { AddMenu } from './AddMenu';
|
|
26
|
-
import { MicButton, SendButton } from './ComposerControls';
|
|
26
|
+
import { MicButton, SendButton, StopButton } from './ComposerControls';
|
|
27
27
|
import { useComposerPopover } from './context';
|
|
28
28
|
import { EffortSlider } from './EffortSlider';
|
|
29
29
|
import {
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
resolveComposerPalette,
|
|
36
36
|
type ComposerPalette,
|
|
37
37
|
} from './shared';
|
|
38
|
-
import type { ComposerPillLabels, ComposerPillProps } from './types';
|
|
38
|
+
import type { ComposerPillLabels, ComposerPillProps, ModelPickerModel } from './types';
|
|
39
39
|
import { dataHook, IS_WEB, useComposerWebCss } from './web-hooks';
|
|
40
40
|
|
|
41
41
|
const EASE = Easing.bezier(0.25, 0.1, 0.25, 1);
|
|
@@ -53,10 +53,12 @@ const DEFAULT_LABELS: Required<ComposerPillLabels> = {
|
|
|
53
53
|
models: 'Models',
|
|
54
54
|
modelGroup: 'Model',
|
|
55
55
|
effort: 'Effort',
|
|
56
|
+
effortAuto: 'Auto',
|
|
56
57
|
faster: 'Faster',
|
|
57
58
|
smarter: 'Smarter',
|
|
58
59
|
voice: 'Voice input',
|
|
59
60
|
send: 'Send message',
|
|
61
|
+
stop: 'Stop generating',
|
|
60
62
|
};
|
|
61
63
|
|
|
62
64
|
/**
|
|
@@ -194,7 +196,10 @@ function ModelRow({
|
|
|
194
196
|
*
|
|
195
197
|
* models pt 4, label → rows 6: "Models" (pl 8, body-medium secondary), then a
|
|
196
198
|
* radio row per model — p 8, radius 10, body-medium primary + 14px
|
|
197
|
-
* radio, primary-hover while selected or hovered, 4 apart
|
|
199
|
+
* radio, primary-hover while selected or hovered, 4 apart. Rows key,
|
|
200
|
+
* match and report by id; a string entry is its own id
|
|
201
|
+
* effort only with stops to choose from — no `effortLevels`, no divider and
|
|
202
|
+
* no slider, rather than a slider that cannot commit
|
|
198
203
|
* divider full bleed (−10 each side), 7 above, 12 below
|
|
199
204
|
* effort "Effort <value>" (the value text-primary, blurring in on change),
|
|
200
205
|
* Faster / Smarter (body-2-medium secondary, px 8 / pt 8 / pb 3) and
|
|
@@ -204,7 +209,7 @@ function ModelRow({
|
|
|
204
209
|
*/
|
|
205
210
|
function ModelMenu({
|
|
206
211
|
models,
|
|
207
|
-
|
|
212
|
+
modelId,
|
|
208
213
|
onModelChange,
|
|
209
214
|
effort,
|
|
210
215
|
onEffortChange,
|
|
@@ -215,10 +220,10 @@ function ModelMenu({
|
|
|
215
220
|
onTriggerLayout,
|
|
216
221
|
testID,
|
|
217
222
|
}: {
|
|
218
|
-
models: ReadonlyArray<
|
|
219
|
-
|
|
220
|
-
onModelChange: (
|
|
221
|
-
effort: number;
|
|
223
|
+
models: ReadonlyArray<ModelPickerModel>;
|
|
224
|
+
modelId: string;
|
|
225
|
+
onModelChange: (modelId: string) => void;
|
|
226
|
+
effort: number | null;
|
|
222
227
|
onEffortChange: (effort: number) => void;
|
|
223
228
|
levels: ReadonlyArray<string>;
|
|
224
229
|
labels: Required<ComposerPillLabels>;
|
|
@@ -231,6 +236,10 @@ function ModelMenu({
|
|
|
231
236
|
const triggerRef = useRef<View>(null);
|
|
232
237
|
const [open, setOpen] = useState(false);
|
|
233
238
|
const [hovered, setHovered] = useState(false);
|
|
239
|
+
// An id with no entry (a model the lineup no longer carries) shows as itself
|
|
240
|
+
// rather than as an empty trigger.
|
|
241
|
+
const modelName = models.find((entry) => entry.id === modelId)?.name ?? modelId;
|
|
242
|
+
const hasEffort = levels.length > 0;
|
|
234
243
|
const triggerStyle: WebCssStyle = {
|
|
235
244
|
height: 32,
|
|
236
245
|
flexShrink: 0,
|
|
@@ -254,7 +263,7 @@ function ModelMenu({
|
|
|
254
263
|
testID={testID}
|
|
255
264
|
{...dataHook('bloomComposerControl')}
|
|
256
265
|
accessibilityRole="button"
|
|
257
|
-
accessibilityLabel={
|
|
266
|
+
accessibilityLabel={modelName}
|
|
258
267
|
aria-expanded={open}
|
|
259
268
|
aria-haspopup="dialog"
|
|
260
269
|
accessibilityState={{ expanded: open }}
|
|
@@ -264,7 +273,7 @@ function ModelMenu({
|
|
|
264
273
|
onLayout={(event) => onTriggerLayout?.(event.nativeEvent.layout.width)}
|
|
265
274
|
style={triggerStyle}>
|
|
266
275
|
<Text variant="body-medium" numberOfLines={1} style={{ paddingLeft: 2, paddingRight: 2, color: palette.textSecondary }}>
|
|
267
|
-
{
|
|
276
|
+
{modelName}
|
|
268
277
|
</Text>
|
|
269
278
|
<TurningChevron degrees={open ? 180 : 0} color={palette.iconSecondary} />
|
|
270
279
|
</Pressable>
|
|
@@ -292,52 +301,63 @@ function ModelMenu({
|
|
|
292
301
|
{labels.models}
|
|
293
302
|
</Text>
|
|
294
303
|
<View role="radiogroup" accessibilityLabel={labels.modelGroup} style={{ width: '100%', flexDirection: 'column', gap: 4 }}>
|
|
295
|
-
{models.map((
|
|
304
|
+
{models.map((entry) => (
|
|
296
305
|
<ModelRow
|
|
297
|
-
key={
|
|
298
|
-
name={name}
|
|
299
|
-
selected={
|
|
306
|
+
key={entry.id}
|
|
307
|
+
name={entry.name}
|
|
308
|
+
selected={entry.id === modelId}
|
|
300
309
|
palette={palette}
|
|
301
310
|
onPress={() => {
|
|
302
|
-
onModelChange(
|
|
311
|
+
onModelChange(entry.id);
|
|
303
312
|
setOpen(false);
|
|
304
313
|
}}
|
|
305
314
|
/>
|
|
306
315
|
))}
|
|
307
316
|
</View>
|
|
308
317
|
</View>
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
<
|
|
313
|
-
{
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
style={{
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
318
|
+
{hasEffort ? (
|
|
319
|
+
<>
|
|
320
|
+
<View style={{ height: 1, marginLeft: -10, marginRight: -10, marginTop: 7, marginBottom: 12, backgroundColor: palette.border }} />
|
|
321
|
+
<View style={{ width: '100%', flexDirection: 'column' }}>
|
|
322
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', paddingLeft: 8 }}>
|
|
323
|
+
<Text variant="body-medium" style={{ color: palette.textSecondary }}>
|
|
324
|
+
{`${labels.effort} `}
|
|
325
|
+
</Text>
|
|
326
|
+
<EffortValue value={effort === null ? labels.effortAuto : (levels[effort] ?? labels.effortAuto)} color={palette.text} />
|
|
327
|
+
</View>
|
|
328
|
+
<View style={{ width: '100%', flexDirection: 'column', gap: 4 }}>
|
|
329
|
+
<View
|
|
330
|
+
style={{
|
|
331
|
+
width: '100%',
|
|
332
|
+
flexDirection: 'row',
|
|
333
|
+
alignItems: 'center',
|
|
334
|
+
justifyContent: 'space-between',
|
|
335
|
+
paddingLeft: 8,
|
|
336
|
+
paddingRight: 8,
|
|
337
|
+
paddingTop: 8,
|
|
338
|
+
paddingBottom: 3,
|
|
339
|
+
}}>
|
|
340
|
+
<Text variant="body-2-medium" style={{ color: palette.textSecondary }}>
|
|
341
|
+
{labels.faster}
|
|
342
|
+
</Text>
|
|
343
|
+
<Text variant="body-2-medium" style={{ color: palette.textSecondary }}>
|
|
344
|
+
{labels.smarter}
|
|
345
|
+
</Text>
|
|
346
|
+
</View>
|
|
347
|
+
<View style={{ width: '100%', paddingLeft: 8, paddingRight: 8, paddingBottom: 8 }}>
|
|
348
|
+
<EffortSlider
|
|
349
|
+
value={effort}
|
|
350
|
+
onChange={onEffortChange}
|
|
351
|
+
levels={levels}
|
|
352
|
+
label={labels.effort}
|
|
353
|
+
unsetLabel={labels.effortAuto}
|
|
354
|
+
palette={palette}
|
|
355
|
+
/>
|
|
356
|
+
</View>
|
|
357
|
+
</View>
|
|
338
358
|
</View>
|
|
339
|
-
|
|
340
|
-
|
|
359
|
+
</>
|
|
360
|
+
) : null}
|
|
341
361
|
</Popover>
|
|
342
362
|
</>
|
|
343
363
|
);
|
|
@@ -355,7 +375,8 @@ function ModelMenu({
|
|
|
355
375
|
* unfocused on web, its last 32px fade into the pill
|
|
356
376
|
* model the model menu trigger (see `ModelMenu`)
|
|
357
377
|
* controls pl 6, gap 8: the bordered mic (dancing accent bars while
|
|
358
|
-
* listening) and the `bg-button-primary` send disc
|
|
378
|
+
* listening) and the `bg-button-primary` send disc — which becomes
|
|
379
|
+
* the stop disc while `busy`, outside `disabled`'s reach
|
|
359
380
|
*
|
|
360
381
|
* `glass` drops the add, model and mic paint (480ms) onto frosted chips, so an
|
|
361
382
|
* active `ComposerLoader` behind the pill shows through them.
|
|
@@ -365,6 +386,8 @@ export function ComposerPillBase({
|
|
|
365
386
|
defaultValue = '',
|
|
366
387
|
onValueChange,
|
|
367
388
|
onSubmit,
|
|
389
|
+
onStop,
|
|
390
|
+
busy = false,
|
|
368
391
|
disabled = false,
|
|
369
392
|
placeholder = 'Ask me anything',
|
|
370
393
|
compactPlaceholder = 'Ask me',
|
|
@@ -400,15 +423,19 @@ export function ComposerPillBase({
|
|
|
400
423
|
defaultValue: defaultListening,
|
|
401
424
|
onChange: onListeningChange,
|
|
402
425
|
});
|
|
403
|
-
const [
|
|
426
|
+
const lineup = useMemo(() => normalizeModels(models ?? []), [models]);
|
|
427
|
+
const [modelId, setModel] = useControllableState<string>({
|
|
404
428
|
value: model,
|
|
405
|
-
defaultValue: defaultModel ??
|
|
429
|
+
defaultValue: defaultModel ?? lineup[0]?.id ?? '',
|
|
406
430
|
onChange: onModelChange,
|
|
407
431
|
});
|
|
408
|
-
const [effortValue, setEffort] = useControllableState<number>({
|
|
432
|
+
const [effortValue, setEffort] = useControllableState<number | null>({
|
|
409
433
|
value: effort,
|
|
410
434
|
defaultValue: defaultEffort,
|
|
411
|
-
|
|
435
|
+
// The menu can only ever land on a stop; `null` is the caller's to hold.
|
|
436
|
+
onChange: (next) => {
|
|
437
|
+
if (next !== null) onEffortChange?.(next);
|
|
438
|
+
},
|
|
412
439
|
});
|
|
413
440
|
const [focused, setFocused] = useState(false);
|
|
414
441
|
const [modelWidth, setModelWidth] = useState(0);
|
|
@@ -433,18 +460,18 @@ export function ComposerPillBase({
|
|
|
433
460
|
);
|
|
434
461
|
|
|
435
462
|
const submit = useCallback(() => {
|
|
436
|
-
if (disabled) return;
|
|
463
|
+
if (disabled || busy) return;
|
|
437
464
|
onSubmit?.(text);
|
|
438
|
-
}, [disabled, onSubmit, text]);
|
|
465
|
+
}, [disabled, busy, onSubmit, text]);
|
|
439
466
|
|
|
440
467
|
const onKeyPress = useCallback(
|
|
441
468
|
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
|
442
469
|
const native: TextInputKeyPressEventData & { isComposing?: boolean } = event.nativeEvent;
|
|
443
|
-
if (!IS_WEB || native.key !== 'Enter' || native.isComposing || disabled) return;
|
|
470
|
+
if (!IS_WEB || native.key !== 'Enter' || native.isComposing || disabled || busy) return;
|
|
444
471
|
event.preventDefault();
|
|
445
472
|
submit();
|
|
446
473
|
},
|
|
447
|
-
[submit, disabled],
|
|
474
|
+
[submit, disabled, busy],
|
|
448
475
|
);
|
|
449
476
|
|
|
450
477
|
const pillStyle: WebCssStyle = {
|
|
@@ -518,12 +545,12 @@ export function ComposerPillBase({
|
|
|
518
545
|
/>
|
|
519
546
|
</View>
|
|
520
547
|
|
|
521
|
-
{
|
|
548
|
+
{lineup.length > 0 ? (
|
|
522
549
|
<View style={{ position: 'relative', flexShrink: 0 }}>
|
|
523
550
|
<GlassChip shown={glass && modelWidth > 0} radius={12} dark={theme.isDark} />
|
|
524
551
|
<ModelMenu
|
|
525
|
-
models={
|
|
526
|
-
|
|
552
|
+
models={lineup}
|
|
553
|
+
modelId={modelId}
|
|
527
554
|
onModelChange={setModel}
|
|
528
555
|
effort={effortValue}
|
|
529
556
|
onEffortChange={setEffort}
|
|
@@ -547,7 +574,11 @@ export function ComposerPillBase({
|
|
|
547
574
|
palette={controlPalette}
|
|
548
575
|
/>
|
|
549
576
|
</View>
|
|
550
|
-
|
|
577
|
+
{busy && onStop ? (
|
|
578
|
+
<StopButton onPress={onStop} label={labels.stop} palette={palette} />
|
|
579
|
+
) : (
|
|
580
|
+
<SendButton disabled={disabled} onPress={submit} label={labels.send} palette={palette} />
|
|
581
|
+
)}
|
|
551
582
|
</View>
|
|
552
583
|
</View>
|
|
553
584
|
);
|
|
@@ -562,5 +593,16 @@ const DEFAULT_PANEL_LABELS = {
|
|
|
562
593
|
learnMore: 'Learn more',
|
|
563
594
|
voice: 'Voice input',
|
|
564
595
|
send: 'Send message',
|
|
596
|
+
stop: 'Stop generating',
|
|
565
597
|
remove: 'Remove',
|
|
566
598
|
};
|
|
599
|
+
|
|
600
|
+
/**
|
|
601
|
+
* A bare string entry is its own id: the shorthand stays exact, and an `{ id,
|
|
602
|
+
* name }` entry keys, matches and reports by id like `ModelPicker` does.
|
|
603
|
+
*/
|
|
604
|
+
function normalizeModels(
|
|
605
|
+
models: ReadonlyArray<string | ModelPickerModel>,
|
|
606
|
+
): ReadonlyArray<ModelPickerModel> {
|
|
607
|
+
return models.map((entry) => (typeof entry === 'string' ? { id: entry, name: entry } : entry));
|
|
608
|
+
}
|