@oxy.so/bloom 3.2.1 → 3.4.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 +3 -3
- package/docs/ai-chat.mdx +31 -3
- package/docs/composer-panel.mdx +41 -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 +186 -85
- 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 +187 -86
- 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 +70 -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 +70 -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 +178 -66
- package/src/composer-panel/EffortSlider.tsx +38 -18
- package/src/composer-panel/ModelPickerBase.tsx +23 -7
- package/src/composer-panel/types.ts +71 -20
- package/src/theme/__tests__/__fixtures__/golden-resolved-tokens.json +7682 -0
- package/src/theme/__tests__/fixtures/color-engine-golden.json +1 -0
|
@@ -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);
|
|
@@ -43,7 +43,10 @@ const EASE_OUT = Easing.bezier(0, 0, 0.58, 1);
|
|
|
43
43
|
const GLASS_MS = 480;
|
|
44
44
|
/** `max-width: 639px`. */
|
|
45
45
|
const COMPACT_WIDTH = 640;
|
|
46
|
+
/** The pill at one line: the field's 20px line box plus 8px padding either side, in a 52 box. */
|
|
46
47
|
const PILL_HEIGHT = 52;
|
|
48
|
+
/** One line of `body-regular`, which is what the field was fixed at. */
|
|
49
|
+
const LINE_HEIGHT = 20;
|
|
47
50
|
|
|
48
51
|
const DEFAULT_LABELS: Required<ComposerPillLabels> = {
|
|
49
52
|
message: 'Message',
|
|
@@ -53,10 +56,12 @@ const DEFAULT_LABELS: Required<ComposerPillLabels> = {
|
|
|
53
56
|
models: 'Models',
|
|
54
57
|
modelGroup: 'Model',
|
|
55
58
|
effort: 'Effort',
|
|
59
|
+
effortAuto: 'Auto',
|
|
56
60
|
faster: 'Faster',
|
|
57
61
|
smarter: 'Smarter',
|
|
58
62
|
voice: 'Voice input',
|
|
59
63
|
send: 'Send message',
|
|
64
|
+
stop: 'Stop generating',
|
|
60
65
|
};
|
|
61
66
|
|
|
62
67
|
/**
|
|
@@ -153,6 +158,13 @@ function ModelRow({
|
|
|
153
158
|
}) {
|
|
154
159
|
const [hovered, setHovered] = useState(false);
|
|
155
160
|
const [focused, setFocused] = useState(false);
|
|
161
|
+
/**
|
|
162
|
+
* The field's measured content height, from `onContentSizeChange`.
|
|
163
|
+
*
|
|
164
|
+
* Measured rather than counted: a draft wraps, so the number of LINES is not
|
|
165
|
+
* the number of newlines, and only the field knows how its own text laid out.
|
|
166
|
+
*/
|
|
167
|
+
const [contentHeight, setContentHeight] = useState(LINE_HEIGHT);
|
|
156
168
|
return (
|
|
157
169
|
<Pressable
|
|
158
170
|
{...dataHook('bloomComposerRow')}
|
|
@@ -194,7 +206,10 @@ function ModelRow({
|
|
|
194
206
|
*
|
|
195
207
|
* models pt 4, label → rows 6: "Models" (pl 8, body-medium secondary), then a
|
|
196
208
|
* radio row per model — p 8, radius 10, body-medium primary + 14px
|
|
197
|
-
* radio, primary-hover while selected or hovered, 4 apart
|
|
209
|
+
* radio, primary-hover while selected or hovered, 4 apart. Rows key,
|
|
210
|
+
* match and report by id; a string entry is its own id
|
|
211
|
+
* effort only with stops to choose from — no `effortLevels`, no divider and
|
|
212
|
+
* no slider, rather than a slider that cannot commit
|
|
198
213
|
* divider full bleed (−10 each side), 7 above, 12 below
|
|
199
214
|
* effort "Effort <value>" (the value text-primary, blurring in on change),
|
|
200
215
|
* Faster / Smarter (body-2-medium secondary, px 8 / pt 8 / pb 3) and
|
|
@@ -204,7 +219,7 @@ function ModelRow({
|
|
|
204
219
|
*/
|
|
205
220
|
function ModelMenu({
|
|
206
221
|
models,
|
|
207
|
-
|
|
222
|
+
modelId,
|
|
208
223
|
onModelChange,
|
|
209
224
|
effort,
|
|
210
225
|
onEffortChange,
|
|
@@ -215,10 +230,10 @@ function ModelMenu({
|
|
|
215
230
|
onTriggerLayout,
|
|
216
231
|
testID,
|
|
217
232
|
}: {
|
|
218
|
-
models: ReadonlyArray<
|
|
219
|
-
|
|
220
|
-
onModelChange: (
|
|
221
|
-
effort: number;
|
|
233
|
+
models: ReadonlyArray<ModelPickerModel>;
|
|
234
|
+
modelId: string;
|
|
235
|
+
onModelChange: (modelId: string) => void;
|
|
236
|
+
effort: number | null;
|
|
222
237
|
onEffortChange: (effort: number) => void;
|
|
223
238
|
levels: ReadonlyArray<string>;
|
|
224
239
|
labels: Required<ComposerPillLabels>;
|
|
@@ -231,6 +246,10 @@ function ModelMenu({
|
|
|
231
246
|
const triggerRef = useRef<View>(null);
|
|
232
247
|
const [open, setOpen] = useState(false);
|
|
233
248
|
const [hovered, setHovered] = useState(false);
|
|
249
|
+
// An id with no entry (a model the lineup no longer carries) shows as itself
|
|
250
|
+
// rather than as an empty trigger.
|
|
251
|
+
const modelName = models.find((entry) => entry.id === modelId)?.name ?? modelId;
|
|
252
|
+
const hasEffort = levels.length > 0;
|
|
234
253
|
const triggerStyle: WebCssStyle = {
|
|
235
254
|
height: 32,
|
|
236
255
|
flexShrink: 0,
|
|
@@ -254,7 +273,7 @@ function ModelMenu({
|
|
|
254
273
|
testID={testID}
|
|
255
274
|
{...dataHook('bloomComposerControl')}
|
|
256
275
|
accessibilityRole="button"
|
|
257
|
-
accessibilityLabel={
|
|
276
|
+
accessibilityLabel={modelName}
|
|
258
277
|
aria-expanded={open}
|
|
259
278
|
aria-haspopup="dialog"
|
|
260
279
|
accessibilityState={{ expanded: open }}
|
|
@@ -264,7 +283,7 @@ function ModelMenu({
|
|
|
264
283
|
onLayout={(event) => onTriggerLayout?.(event.nativeEvent.layout.width)}
|
|
265
284
|
style={triggerStyle}>
|
|
266
285
|
<Text variant="body-medium" numberOfLines={1} style={{ paddingLeft: 2, paddingRight: 2, color: palette.textSecondary }}>
|
|
267
|
-
{
|
|
286
|
+
{modelName}
|
|
268
287
|
</Text>
|
|
269
288
|
<TurningChevron degrees={open ? 180 : 0} color={palette.iconSecondary} />
|
|
270
289
|
</Pressable>
|
|
@@ -292,52 +311,63 @@ function ModelMenu({
|
|
|
292
311
|
{labels.models}
|
|
293
312
|
</Text>
|
|
294
313
|
<View role="radiogroup" accessibilityLabel={labels.modelGroup} style={{ width: '100%', flexDirection: 'column', gap: 4 }}>
|
|
295
|
-
{models.map((
|
|
314
|
+
{models.map((entry) => (
|
|
296
315
|
<ModelRow
|
|
297
|
-
key={
|
|
298
|
-
name={name}
|
|
299
|
-
selected={
|
|
316
|
+
key={entry.id}
|
|
317
|
+
name={entry.name}
|
|
318
|
+
selected={entry.id === modelId}
|
|
300
319
|
palette={palette}
|
|
301
320
|
onPress={() => {
|
|
302
|
-
onModelChange(
|
|
321
|
+
onModelChange(entry.id);
|
|
303
322
|
setOpen(false);
|
|
304
323
|
}}
|
|
305
324
|
/>
|
|
306
325
|
))}
|
|
307
326
|
</View>
|
|
308
327
|
</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
|
-
|
|
328
|
+
{hasEffort ? (
|
|
329
|
+
<>
|
|
330
|
+
<View style={{ height: 1, marginLeft: -10, marginRight: -10, marginTop: 7, marginBottom: 12, backgroundColor: palette.border }} />
|
|
331
|
+
<View style={{ width: '100%', flexDirection: 'column' }}>
|
|
332
|
+
<View style={{ flexDirection: 'row', alignItems: 'center', paddingLeft: 8 }}>
|
|
333
|
+
<Text variant="body-medium" style={{ color: palette.textSecondary }}>
|
|
334
|
+
{`${labels.effort} `}
|
|
335
|
+
</Text>
|
|
336
|
+
<EffortValue value={effort === null ? labels.effortAuto : (levels[effort] ?? labels.effortAuto)} color={palette.text} />
|
|
337
|
+
</View>
|
|
338
|
+
<View style={{ width: '100%', flexDirection: 'column', gap: 4 }}>
|
|
339
|
+
<View
|
|
340
|
+
style={{
|
|
341
|
+
width: '100%',
|
|
342
|
+
flexDirection: 'row',
|
|
343
|
+
alignItems: 'center',
|
|
344
|
+
justifyContent: 'space-between',
|
|
345
|
+
paddingLeft: 8,
|
|
346
|
+
paddingRight: 8,
|
|
347
|
+
paddingTop: 8,
|
|
348
|
+
paddingBottom: 3,
|
|
349
|
+
}}>
|
|
350
|
+
<Text variant="body-2-medium" style={{ color: palette.textSecondary }}>
|
|
351
|
+
{labels.faster}
|
|
352
|
+
</Text>
|
|
353
|
+
<Text variant="body-2-medium" style={{ color: palette.textSecondary }}>
|
|
354
|
+
{labels.smarter}
|
|
355
|
+
</Text>
|
|
356
|
+
</View>
|
|
357
|
+
<View style={{ width: '100%', paddingLeft: 8, paddingRight: 8, paddingBottom: 8 }}>
|
|
358
|
+
<EffortSlider
|
|
359
|
+
value={effort}
|
|
360
|
+
onChange={onEffortChange}
|
|
361
|
+
levels={levels}
|
|
362
|
+
label={labels.effort}
|
|
363
|
+
unsetLabel={labels.effortAuto}
|
|
364
|
+
palette={palette}
|
|
365
|
+
/>
|
|
366
|
+
</View>
|
|
367
|
+
</View>
|
|
338
368
|
</View>
|
|
339
|
-
|
|
340
|
-
|
|
369
|
+
</>
|
|
370
|
+
) : null}
|
|
341
371
|
</Popover>
|
|
342
372
|
</>
|
|
343
373
|
);
|
|
@@ -355,7 +385,8 @@ function ModelMenu({
|
|
|
355
385
|
* unfocused on web, its last 32px fade into the pill
|
|
356
386
|
* model the model menu trigger (see `ModelMenu`)
|
|
357
387
|
* controls pl 6, gap 8: the bordered mic (dancing accent bars while
|
|
358
|
-
* listening) and the `bg-button-primary` send disc
|
|
388
|
+
* listening) and the `bg-button-primary` send disc — which becomes
|
|
389
|
+
* the stop disc while `busy`, outside `disabled`'s reach
|
|
359
390
|
*
|
|
360
391
|
* `glass` drops the add, model and mic paint (480ms) onto frosted chips, so an
|
|
361
392
|
* active `ComposerLoader` behind the pill shows through them.
|
|
@@ -365,6 +396,8 @@ export function ComposerPillBase({
|
|
|
365
396
|
defaultValue = '',
|
|
366
397
|
onValueChange,
|
|
367
398
|
onSubmit,
|
|
399
|
+
onStop,
|
|
400
|
+
busy = false,
|
|
368
401
|
disabled = false,
|
|
369
402
|
placeholder = 'Ask me anything',
|
|
370
403
|
compactPlaceholder = 'Ask me',
|
|
@@ -386,6 +419,7 @@ export function ComposerPillBase({
|
|
|
386
419
|
inputRef,
|
|
387
420
|
labels: labelOverrides,
|
|
388
421
|
style,
|
|
422
|
+
maxLines = 8,
|
|
389
423
|
testID,
|
|
390
424
|
}: ComposerPillProps) {
|
|
391
425
|
useComposerWebCss();
|
|
@@ -400,17 +434,28 @@ export function ComposerPillBase({
|
|
|
400
434
|
defaultValue: defaultListening,
|
|
401
435
|
onChange: onListeningChange,
|
|
402
436
|
});
|
|
403
|
-
const [
|
|
437
|
+
const lineup = useMemo(() => normalizeModels(models ?? []), [models]);
|
|
438
|
+
const [modelId, setModel] = useControllableState<string>({
|
|
404
439
|
value: model,
|
|
405
|
-
defaultValue: defaultModel ??
|
|
440
|
+
defaultValue: defaultModel ?? lineup[0]?.id ?? '',
|
|
406
441
|
onChange: onModelChange,
|
|
407
442
|
});
|
|
408
|
-
const [effortValue, setEffort] = useControllableState<number>({
|
|
443
|
+
const [effortValue, setEffort] = useControllableState<number | null>({
|
|
409
444
|
value: effort,
|
|
410
445
|
defaultValue: defaultEffort,
|
|
411
|
-
|
|
446
|
+
// The menu can only ever land on a stop; `null` is the caller's to hold.
|
|
447
|
+
onChange: (next) => {
|
|
448
|
+
if (next !== null) onEffortChange?.(next);
|
|
449
|
+
},
|
|
412
450
|
});
|
|
413
451
|
const [focused, setFocused] = useState(false);
|
|
452
|
+
/**
|
|
453
|
+
* The field's measured content height, from `onContentSizeChange`.
|
|
454
|
+
*
|
|
455
|
+
* Measured rather than counted: a draft wraps, so the number of LINES is not
|
|
456
|
+
* the number of newlines, and only the field knows how its own text laid out.
|
|
457
|
+
*/
|
|
458
|
+
const [contentHeight, setContentHeight] = useState(LINE_HEIGHT);
|
|
414
459
|
const [modelWidth, setModelWidth] = useState(0);
|
|
415
460
|
|
|
416
461
|
// The chat's add surface is a step lighter than the Composer Panel's:
|
|
@@ -433,27 +478,72 @@ export function ComposerPillBase({
|
|
|
433
478
|
);
|
|
434
479
|
|
|
435
480
|
const submit = useCallback(() => {
|
|
436
|
-
if (disabled) return;
|
|
481
|
+
if (disabled || busy) return;
|
|
437
482
|
onSubmit?.(text);
|
|
438
|
-
}, [disabled, onSubmit, text]);
|
|
483
|
+
}, [disabled, busy, onSubmit, text]);
|
|
439
484
|
|
|
440
485
|
const onKeyPress = useCallback(
|
|
441
486
|
(event: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
|
442
|
-
const native: TextInputKeyPressEventData & { isComposing?: boolean } = event.nativeEvent;
|
|
443
|
-
|
|
487
|
+
const native: TextInputKeyPressEventData & { shiftKey?: boolean; isComposing?: boolean } = event.nativeEvent;
|
|
488
|
+
/*
|
|
489
|
+
* Shift+Enter is a NEWLINE, not a send.
|
|
490
|
+
*
|
|
491
|
+
* This handler used to read only the key, so a person holding shift to
|
|
492
|
+
* break a line sent the half-written message instead — on a field that
|
|
493
|
+
* could not have held the line anyway. `ComposerPanelBase` has always
|
|
494
|
+
* had the clause (`… || native.shiftKey || …`); the pill did not, and
|
|
495
|
+
* the two composers disagreeing about the most-used key in a composer
|
|
496
|
+
* was a difference nobody chose.
|
|
497
|
+
*/
|
|
498
|
+
if (
|
|
499
|
+
!IS_WEB
|
|
500
|
+
|| native.key !== 'Enter'
|
|
501
|
+
|| native.shiftKey
|
|
502
|
+
|| native.isComposing
|
|
503
|
+
|| disabled
|
|
504
|
+
|| busy
|
|
505
|
+
) {
|
|
506
|
+
return;
|
|
507
|
+
}
|
|
444
508
|
event.preventDefault();
|
|
445
509
|
submit();
|
|
446
510
|
},
|
|
447
|
-
[submit, disabled],
|
|
511
|
+
[submit, disabled, busy],
|
|
448
512
|
);
|
|
449
513
|
|
|
514
|
+
/*
|
|
515
|
+
* How tall the pill is: one line, or as many as the draft needs up to
|
|
516
|
+
* `maxLines`.
|
|
517
|
+
*
|
|
518
|
+
* It was `height: PILL_HEIGHT` with a `height: 20` field and no `multiline`
|
|
519
|
+
* — under react-native-web that is an `<input>`, which cannot hold a newline
|
|
520
|
+
* and cannot grow. A composer for an assistant has to hold a paragraph, so
|
|
521
|
+
* the field is multiline and the pill measures it.
|
|
522
|
+
*
|
|
523
|
+
* The radius stays at 9999 while it is one line and squares off to 26 beyond
|
|
524
|
+
* that: a fully-round multi-line box puts the first and last lines inside
|
|
525
|
+
* the curve, where the text meets the edge.
|
|
526
|
+
*/
|
|
527
|
+
const lines = Math.min(maxLines, Math.max(1, Math.ceil(contentHeight / LINE_HEIGHT) || 1));
|
|
528
|
+
const fieldHeight = lines * LINE_HEIGHT;
|
|
529
|
+
/*
|
|
530
|
+
* At one line the pill is exactly what it always was. Its 52 comes from the
|
|
531
|
+
* CONTROLS — a 36px disc inside 8px padding — not from the 20px field, so
|
|
532
|
+
* growing from the field's height would have SHRUNK the resting pill to 36.
|
|
533
|
+
* Each line after the first adds its own height and nothing else moves.
|
|
534
|
+
*/
|
|
535
|
+
const pillHeight = PILL_HEIGHT + (lines - 1) * LINE_HEIGHT;
|
|
536
|
+
const multiLine = lines > 1;
|
|
537
|
+
|
|
450
538
|
const pillStyle: WebCssStyle = {
|
|
451
539
|
width: '100%',
|
|
452
|
-
height:
|
|
540
|
+
height: pillHeight,
|
|
453
541
|
flexDirection: 'row',
|
|
454
|
-
|
|
542
|
+
// The controls sit with the LAST line once the field has grown, which is
|
|
543
|
+
// where the caret is; centred, they would drift to the middle of the draft.
|
|
544
|
+
alignItems: multiLine ? 'flex-end' : 'center',
|
|
455
545
|
gap: 10,
|
|
456
|
-
borderRadius: 9999,
|
|
546
|
+
borderRadius: multiLine ? 26 : 9999,
|
|
457
547
|
padding: 8,
|
|
458
548
|
backgroundColor: surface ? palette.surface : 'transparent',
|
|
459
549
|
boxShadow: surface ? palette.shadowXs : undefined,
|
|
@@ -461,10 +551,12 @@ export function ComposerPillBase({
|
|
|
461
551
|
};
|
|
462
552
|
|
|
463
553
|
const fieldStyle: WebCssStyle = {
|
|
464
|
-
height:
|
|
554
|
+
height: fieldHeight,
|
|
465
555
|
minWidth: 0,
|
|
466
556
|
flex: 1,
|
|
467
|
-
|
|
557
|
+
// The fade that hints at overflowing text is a ONE-LINE affordance: on a
|
|
558
|
+
// grown field it would dim the end of every line.
|
|
559
|
+
...(IS_WEB && !focused && !multiLine
|
|
468
560
|
? {
|
|
469
561
|
maskImage: 'linear-gradient(to right, #000 calc(100% - 32px), transparent 100%)',
|
|
470
562
|
WebkitMaskImage: 'linear-gradient(to right, #000 calc(100% - 32px), transparent 100%)',
|
|
@@ -504,9 +596,14 @@ export function ComposerPillBase({
|
|
|
504
596
|
selectionColor={palette.accent500}
|
|
505
597
|
cursorColor={palette.accent500}
|
|
506
598
|
returnKeyType="send"
|
|
599
|
+
multiline
|
|
600
|
+
onContentSizeChange={(event) => setContentHeight(event.nativeEvent.contentSize.height)}
|
|
507
601
|
style={{
|
|
508
602
|
width: '100%',
|
|
509
|
-
height:
|
|
603
|
+
height: fieldHeight,
|
|
604
|
+
// react-native-web gives a multiline field a resize grip and a
|
|
605
|
+
// scrollbar it does not need: the pill owns the height.
|
|
606
|
+
...(IS_WEB ? { resize: 'none', overflowY: lines >= maxLines ? 'auto' : 'hidden' } : null),
|
|
510
607
|
padding: 0,
|
|
511
608
|
margin: 0,
|
|
512
609
|
...TYPE_SCALE['body-regular'],
|
|
@@ -518,12 +615,12 @@ export function ComposerPillBase({
|
|
|
518
615
|
/>
|
|
519
616
|
</View>
|
|
520
617
|
|
|
521
|
-
{
|
|
618
|
+
{lineup.length > 0 ? (
|
|
522
619
|
<View style={{ position: 'relative', flexShrink: 0 }}>
|
|
523
620
|
<GlassChip shown={glass && modelWidth > 0} radius={12} dark={theme.isDark} />
|
|
524
621
|
<ModelMenu
|
|
525
|
-
models={
|
|
526
|
-
|
|
622
|
+
models={lineup}
|
|
623
|
+
modelId={modelId}
|
|
527
624
|
onModelChange={setModel}
|
|
528
625
|
effort={effortValue}
|
|
529
626
|
onEffortChange={setEffort}
|
|
@@ -547,7 +644,11 @@ export function ComposerPillBase({
|
|
|
547
644
|
palette={controlPalette}
|
|
548
645
|
/>
|
|
549
646
|
</View>
|
|
550
|
-
|
|
647
|
+
{busy && onStop ? (
|
|
648
|
+
<StopButton onPress={onStop} label={labels.stop} palette={palette} />
|
|
649
|
+
) : (
|
|
650
|
+
<SendButton disabled={disabled} onPress={submit} label={labels.send} palette={palette} />
|
|
651
|
+
)}
|
|
551
652
|
</View>
|
|
552
653
|
</View>
|
|
553
654
|
);
|
|
@@ -562,5 +663,16 @@ const DEFAULT_PANEL_LABELS = {
|
|
|
562
663
|
learnMore: 'Learn more',
|
|
563
664
|
voice: 'Voice input',
|
|
564
665
|
send: 'Send message',
|
|
666
|
+
stop: 'Stop generating',
|
|
565
667
|
remove: 'Remove',
|
|
566
668
|
};
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* A bare string entry is its own id: the shorthand stays exact, and an `{ id,
|
|
672
|
+
* name }` entry keys, matches and reports by id like `ModelPicker` does.
|
|
673
|
+
*/
|
|
674
|
+
function normalizeModels(
|
|
675
|
+
models: ReadonlyArray<string | ModelPickerModel>,
|
|
676
|
+
): ReadonlyArray<ModelPickerModel> {
|
|
677
|
+
return models.map((entry) => (typeof entry === 'string' ? { id: entry, name: entry } : entry));
|
|
678
|
+
}
|
|
@@ -9,10 +9,13 @@ const TRACK_HEIGHT = 27;
|
|
|
9
9
|
const THUMB_WIDTH = 21;
|
|
10
10
|
|
|
11
11
|
interface EffortSliderProps {
|
|
12
|
-
|
|
12
|
+
/** The chosen stop, or `null` for no choice yet. */
|
|
13
|
+
value: number | null;
|
|
13
14
|
onChange: (value: number) => void;
|
|
14
15
|
levels: ReadonlyArray<string>;
|
|
15
16
|
label: string;
|
|
17
|
+
/** What `aria-valuetext` reads with no stop chosen (the "Auto" label). */
|
|
18
|
+
unsetLabel: string;
|
|
16
19
|
palette: ComposerPalette;
|
|
17
20
|
testID?: string;
|
|
18
21
|
}
|
|
@@ -25,6 +28,11 @@ interface EffortSliderProps {
|
|
|
25
28
|
* past the value fade their tick to 30% over 300ms; the fill follows the thumb
|
|
26
29
|
* over 150ms.
|
|
27
30
|
*
|
|
31
|
+
* With `value` `null` nothing is committed: the fill is empty, every tick is
|
|
32
|
+
* faded and the thumb is hollow and dashed, so "no stop chosen" cannot be read
|
|
33
|
+
* as "the first stop". The first press or key commits a stop, and there is no
|
|
34
|
+
* way back — the caller owns the `null`.
|
|
35
|
+
*
|
|
28
36
|
* Drag or press anywhere on the track (PanResponder on both platforms); on web
|
|
29
37
|
* the thumb is focusable and answers the arrow keys, Home and End.
|
|
30
38
|
*
|
|
@@ -32,20 +40,25 @@ interface EffortSliderProps {
|
|
|
32
40
|
* starfield, with the ticks blown off the track) has no native counterpart,
|
|
33
41
|
* so at Max the slider simply rests at its last stop.
|
|
34
42
|
*/
|
|
35
|
-
export function EffortSlider({ value, onChange, levels, label, palette, testID }: EffortSliderProps) {
|
|
43
|
+
export function EffortSlider({ value, onChange, levels, label, unsetLabel, palette, testID }: EffortSliderProps) {
|
|
36
44
|
const [width, setWidth] = useState(0);
|
|
37
45
|
const max = Math.max(0, levels.length - 1);
|
|
38
|
-
const
|
|
39
|
-
const fraction = max > 0 ?
|
|
46
|
+
const chosen = value === null ? null : Math.min(Math.max(value, 0), max);
|
|
47
|
+
const fraction = chosen !== null && max > 0 ? chosen / max : 0;
|
|
40
48
|
const rail = Math.max(0, width - THUMB_WIDTH);
|
|
41
49
|
|
|
42
|
-
const stateRef = useRef
|
|
43
|
-
|
|
44
|
-
|
|
50
|
+
const stateRef = useRef<{ value: number | null; rail: number; max: number; onChange: (next: number) => void }>({
|
|
51
|
+
value: chosen,
|
|
52
|
+
rail,
|
|
53
|
+
max,
|
|
54
|
+
onChange,
|
|
55
|
+
});
|
|
56
|
+
stateRef.current = { value: chosen, rail, max, onChange };
|
|
57
|
+
const startRef = useRef(chosen ?? 0);
|
|
45
58
|
|
|
46
59
|
const valueAt = useCallback((x: number) => {
|
|
47
60
|
const s = stateRef.current;
|
|
48
|
-
if (s.rail <= 0 || s.max <= 0) return s.value;
|
|
61
|
+
if (s.rail <= 0 || s.max <= 0) return s.value ?? 0;
|
|
49
62
|
const f = Math.min(Math.max((x - THUMB_WIDTH / 2) / s.rail, 0), 1);
|
|
50
63
|
return Math.round(f * s.max);
|
|
51
64
|
}, []);
|
|
@@ -83,11 +96,17 @@ export function EffortSlider({ value, onChange, levels, label, palette, testID }
|
|
|
83
96
|
tabIndex: 0,
|
|
84
97
|
onKeyDown: (event: { key: string; preventDefault: () => void }) => {
|
|
85
98
|
const s = stateRef.current;
|
|
99
|
+
// With nothing chosen, either arrow commits the first stop.
|
|
100
|
+
const current = s.value;
|
|
86
101
|
const next =
|
|
87
102
|
event.key === 'ArrowRight' || event.key === 'ArrowUp'
|
|
88
|
-
?
|
|
103
|
+
? current === null
|
|
104
|
+
? 0
|
|
105
|
+
: Math.min(s.max, current + 1)
|
|
89
106
|
: event.key === 'ArrowLeft' || event.key === 'ArrowDown'
|
|
90
|
-
?
|
|
107
|
+
? current === null
|
|
108
|
+
? 0
|
|
109
|
+
: Math.max(0, current - 1)
|
|
91
110
|
: event.key === 'Home'
|
|
92
111
|
? 0
|
|
93
112
|
: event.key === 'End'
|
|
@@ -108,9 +127,10 @@ export function EffortSlider({ value, onChange, levels, label, palette, testID }
|
|
|
108
127
|
height: TRACK_HEIGHT,
|
|
109
128
|
borderRadius: 7,
|
|
110
129
|
borderWidth: 1,
|
|
130
|
+
borderStyle: chosen === null ? 'dashed' : 'solid',
|
|
111
131
|
borderColor: palette.thumbBorder,
|
|
112
|
-
backgroundColor: palette.surface,
|
|
113
|
-
boxShadow: palette.shadowXs,
|
|
132
|
+
backgroundColor: chosen === null ? 'transparent' : palette.surface,
|
|
133
|
+
boxShadow: chosen === null ? undefined : palette.shadowXs,
|
|
114
134
|
'--bloom-composer-ring': palette.focusRing,
|
|
115
135
|
};
|
|
116
136
|
|
|
@@ -121,12 +141,12 @@ export function EffortSlider({ value, onChange, levels, label, palette, testID }
|
|
|
121
141
|
accessibilityLabel={label}
|
|
122
142
|
aria-valuemin={0}
|
|
123
143
|
aria-valuemax={max}
|
|
124
|
-
aria-valuenow={
|
|
125
|
-
aria-valuetext={levels[
|
|
144
|
+
aria-valuenow={chosen ?? undefined}
|
|
145
|
+
aria-valuetext={chosen === null ? unsetLabel : levels[chosen]}
|
|
126
146
|
accessibilityActions={[{ name: 'increment' }, { name: 'decrement' }]}
|
|
127
147
|
onAccessibilityAction={(event) => {
|
|
128
|
-
if (event.nativeEvent.actionName === 'increment') commit(Math.min(max,
|
|
129
|
-
if (event.nativeEvent.actionName === 'decrement') commit(Math.max(0,
|
|
148
|
+
if (event.nativeEvent.actionName === 'increment') commit(chosen === null ? 0 : Math.min(max, chosen + 1));
|
|
149
|
+
if (event.nativeEvent.actionName === 'decrement') commit(chosen === null ? 0 : Math.max(0, chosen - 1));
|
|
130
150
|
}}
|
|
131
151
|
style={{ width: '100%' }}>
|
|
132
152
|
<View
|
|
@@ -149,7 +169,7 @@ export function EffortSlider({ value, onChange, levels, label, palette, testID }
|
|
|
149
169
|
top: 0,
|
|
150
170
|
bottom: 0,
|
|
151
171
|
left: 0,
|
|
152
|
-
width: fraction * rail + THUMB_WIDTH,
|
|
172
|
+
width: chosen === null ? 0 : fraction * rail + THUMB_WIDTH,
|
|
153
173
|
borderRadius: 8,
|
|
154
174
|
backgroundColor: palette.tertiaryHover,
|
|
155
175
|
}}
|
|
@@ -175,7 +195,7 @@ export function EffortSlider({ value, onChange, levels, label, palette, testID }
|
|
|
175
195
|
height: 13,
|
|
176
196
|
borderRadius: 2,
|
|
177
197
|
backgroundColor: palette.iconTertiary,
|
|
178
|
-
opacity: index >
|
|
198
|
+
opacity: chosen === null || index > chosen ? 0.3 : 1,
|
|
179
199
|
}}
|
|
180
200
|
/>
|
|
181
201
|
))}
|
|
@@ -57,6 +57,7 @@ const DEFAULT_LABELS: Required<ModelPickerLabels> = {
|
|
|
57
57
|
noMatches: 'No models match',
|
|
58
58
|
providers: 'Providers',
|
|
59
59
|
effort: 'Effort',
|
|
60
|
+
effortAuto: 'Auto',
|
|
60
61
|
faster: 'Faster',
|
|
61
62
|
smarter: 'Smarter',
|
|
62
63
|
};
|
|
@@ -218,6 +219,11 @@ function BlurInLevel({ level, palette }: { level: string; palette: ComposerPalet
|
|
|
218
219
|
);
|
|
219
220
|
}
|
|
220
221
|
|
|
222
|
+
/**
|
|
223
|
+
* The effort chip on the checked row. Drawn only where there are stops to
|
|
224
|
+
* choose from — a model with no effort axis gets no chip, rather than an empty
|
|
225
|
+
* one over a slider that cannot commit.
|
|
226
|
+
*/
|
|
221
227
|
function EffortMenu({
|
|
222
228
|
value,
|
|
223
229
|
onChange,
|
|
@@ -225,7 +231,7 @@ function EffortMenu({
|
|
|
225
231
|
labels,
|
|
226
232
|
palette,
|
|
227
233
|
}: {
|
|
228
|
-
value: number;
|
|
234
|
+
value: number | null;
|
|
229
235
|
onChange: (next: number) => void;
|
|
230
236
|
levels: ReadonlyArray<string>;
|
|
231
237
|
labels: Required<ModelPickerLabels>;
|
|
@@ -235,7 +241,7 @@ function EffortMenu({
|
|
|
235
241
|
const triggerRef = useRef<View>(null);
|
|
236
242
|
const [open, setOpen] = useState(false);
|
|
237
243
|
const [hovered, setHovered] = useState(false);
|
|
238
|
-
const level =
|
|
244
|
+
const level = value === null ? labels.effortAuto : (levels[value] ?? labels.effortAuto);
|
|
239
245
|
|
|
240
246
|
const chipStyle: WebCssStyle = {
|
|
241
247
|
flexShrink: 0,
|
|
@@ -314,7 +320,14 @@ function EffortMenu({
|
|
|
314
320
|
</Text>
|
|
315
321
|
</View>
|
|
316
322
|
<View style={{ paddingLeft: 8, paddingRight: 8, paddingBottom: 8 }}>
|
|
317
|
-
<EffortSlider
|
|
323
|
+
<EffortSlider
|
|
324
|
+
value={value}
|
|
325
|
+
onChange={onChange}
|
|
326
|
+
levels={levels}
|
|
327
|
+
label={labels.effort}
|
|
328
|
+
unsetLabel={labels.effortAuto}
|
|
329
|
+
palette={palette}
|
|
330
|
+
/>
|
|
318
331
|
</View>
|
|
319
332
|
</View>
|
|
320
333
|
</View>
|
|
@@ -388,7 +401,7 @@ function ModelRow({
|
|
|
388
401
|
{searching ? <InlineAside color={palette.textTertiary}>{provider.name}</InlineAside> : null}
|
|
389
402
|
</Text>
|
|
390
403
|
</View>
|
|
391
|
-
{
|
|
404
|
+
{effort ? <View style={{ flexShrink: 0, flexDirection: 'row' }}>{effort}</View> : null}
|
|
392
405
|
</View>
|
|
393
406
|
<View pointerEvents="none">
|
|
394
407
|
<RadioIndicator selected={checked} size={14} />
|
|
@@ -447,10 +460,13 @@ export function ModelPickerBase({
|
|
|
447
460
|
defaultValue: defaultValue ?? providers[0]?.models[0]?.id ?? '',
|
|
448
461
|
onChange: onValueChange,
|
|
449
462
|
});
|
|
450
|
-
const [effortValue, setEffort] = useControllableState<number>({
|
|
463
|
+
const [effortValue, setEffort] = useControllableState<number | null>({
|
|
451
464
|
value: effort,
|
|
452
465
|
defaultValue: defaultEffort,
|
|
453
|
-
|
|
466
|
+
// The picker can only ever land on a stop; `null` is the caller's to hold.
|
|
467
|
+
onChange: (next) => {
|
|
468
|
+
if (next !== null) onEffortChange?.(next);
|
|
469
|
+
},
|
|
454
470
|
});
|
|
455
471
|
|
|
456
472
|
const selected: Match | null =
|
|
@@ -724,7 +740,7 @@ export function ModelPickerBase({
|
|
|
724
740
|
}
|
|
725
741
|
}}
|
|
726
742
|
effort={
|
|
727
|
-
checked ? (
|
|
743
|
+
checked && effortLevels.length > 0 ? (
|
|
728
744
|
<EffortMenu
|
|
729
745
|
value={effortValue}
|
|
730
746
|
onChange={setEffort}
|