@vvedantb/native-ui 0.13.0 → 0.17.1

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 (111) hide show
  1. package/README.md +42 -21
  2. package/package.json +36 -3
  3. package/src/BottomSheetContainer.tsx +2 -2
  4. package/src/BulkActionBar.tsx +240 -105
  5. package/src/Button.tsx +11 -7
  6. package/src/ConfirmDialog.tsx +25 -35
  7. package/src/Dialog.tsx +47 -35
  8. package/src/DividedList.tsx +11 -7
  9. package/src/DropdownMenu.tsx +22 -8
  10. package/src/FloatingSurface.tsx +5 -2
  11. package/src/FloatingTabBar.tsx +13 -22
  12. package/src/HeaderIconButton.tsx +4 -3
  13. package/src/Input.tsx +2 -2
  14. package/src/LoadingState.tsx +132 -0
  15. package/src/PageTitle.tsx +9 -2
  16. package/src/Pager.tsx +62 -0
  17. package/src/PasswordInput.tsx +1 -1
  18. package/src/PillTabs.tsx +144 -34
  19. package/src/Progress.tsx +9 -7
  20. package/src/Select.tsx +4 -4
  21. package/src/Sheet.tsx +29 -22
  22. package/src/SheetRow.tsx +27 -9
  23. package/src/Skeleton.tsx +91 -9
  24. package/src/SurfaceRow.tsx +111 -0
  25. package/src/SwipeableRow.tsx +43 -0
  26. package/src/Switch.tsx +6 -4
  27. package/src/Tabs.tsx +68 -13
  28. package/src/Toast.tsx +107 -65
  29. package/src/ai/activity-step-detail.tsx +1 -3
  30. package/src/ai/activity-step-label.ts +981 -0
  31. package/src/ai/activity-step-types.ts +107 -0
  32. package/src/ai/activity-tasks-utils.ts +199 -0
  33. package/src/ai/activity-timeline.ts +45 -0
  34. package/src/ai/attachments.tsx +3 -1
  35. package/src/ai/chat-text.ts +13 -7
  36. package/src/ai/chat-thread.tsx +34 -27
  37. package/src/ai/choice-question.tsx +154 -35
  38. package/src/ai/code-block.tsx +189 -49
  39. package/src/ai/collapsible.tsx +23 -13
  40. package/src/ai/conversation.tsx +394 -57
  41. package/src/ai/icon-button.tsx +41 -6
  42. package/src/ai/index.ts +55 -4
  43. package/src/ai/message-action-sheet.tsx +4 -1
  44. package/src/ai/message.tsx +60 -31
  45. package/src/ai/prompt-input-menu.tsx +197 -0
  46. package/src/ai/prompt-input.tsx +301 -75
  47. package/src/ai/shimmer.tsx +4 -6
  48. package/src/ai/subagent-cards.tsx +84 -14
  49. package/src/ai/tool-approval-card.tsx +139 -8
  50. package/src/ai/trace.tsx +54 -17
  51. package/src/ai/unified-diff.tsx +182 -0
  52. package/src/auth/GoogleSignInButton.tsx +6 -3
  53. package/src/auth/SignInScreen.tsx +11 -2
  54. package/src/brand/ProviderIcon.tsx +136 -0
  55. package/src/brand/index.ts +2 -0
  56. package/src/configureTheme.ts +39 -11
  57. package/src/controlStyles.ts +46 -30
  58. package/src/entries/gesture-handler.ts +4 -0
  59. package/src/entries/keyboard-controller.ts +4 -0
  60. package/src/entries/markdown.ts +1 -1
  61. package/src/entries/primitives.ts +24 -3
  62. package/src/entries/theme.ts +2 -1
  63. package/src/entries/video.ts +4 -0
  64. package/src/entries/webview.ts +6 -0
  65. package/src/examples/AiChatGallery.tsx +70 -0
  66. package/src/floating.ts +25 -4
  67. package/src/haptics.ts +30 -4
  68. package/src/highlight.ts +302 -1
  69. package/src/index.ts +27 -5
  70. package/src/loadingGrid.ts +75 -0
  71. package/src/markdown/Markdown.tsx +234 -32
  72. package/src/markdown/MarkdownTable.tsx +188 -0
  73. package/src/markdown/tableText.ts +58 -0
  74. package/src/marketing/UpgradeToProCard.tsx +4 -3
  75. package/src/media/ImageGallery.tsx +224 -0
  76. package/src/media/MediaCard.tsx +100 -0
  77. package/src/media/imageGrid.ts +51 -0
  78. package/src/motion.ts +33 -0
  79. package/src/pagerSwipe.ts +102 -0
  80. package/src/palette.ts +8 -1
  81. package/src/primitives/button.tsx +4 -1
  82. package/src/primitives/keyboard-controller.tsx +106 -0
  83. package/src/primitives/keyboard-parts.tsx +104 -0
  84. package/src/primitives/overlay.tsx +133 -45
  85. package/src/primitives/pager-gesture.tsx +131 -0
  86. package/src/primitives/sheet-drag.tsx +305 -0
  87. package/src/primitives/sheet-gesture.tsx +181 -0
  88. package/src/primitives/swipe-row-gesture.tsx +290 -0
  89. package/src/primitives/toast-drag-gesture.tsx +92 -0
  90. package/src/primitives/web-click-guard.ts +50 -0
  91. package/src/relativeTime.ts +94 -0
  92. package/src/router/RouterStackPageHeader.tsx +2 -2
  93. package/src/settings/SettingsEmptyState.tsx +20 -10
  94. package/src/settings/SettingsField.tsx +11 -5
  95. package/src/settings/SettingsSection.tsx +8 -5
  96. package/src/settings/SettingsToggleRow.tsx +55 -22
  97. package/src/settings/relaxedSizeStyle.ts +11 -0
  98. package/src/sheetDrag.ts +67 -0
  99. package/src/swipeRow.ts +169 -0
  100. package/src/tabBarRoutes.ts +18 -0
  101. package/src/tabIndicator.ts +71 -0
  102. package/src/tailwind-preset.cjs +13 -6
  103. package/src/tailwind-preset.d.cts +5 -0
  104. package/src/theme.tsx +6 -2
  105. package/src/toastDrag.ts +58 -0
  106. package/src/typography.ts +20 -3
  107. package/src/useScheme.ts +10 -1
  108. package/src/video/InlineVideo.tsx +91 -0
  109. package/src/webview/HtmlView.tsx +91 -0
  110. package/src/webview/HtmlView.web.tsx +21 -0
  111. package/src/webview/htmlView.ts +45 -0
@@ -1,14 +1,18 @@
1
- import { useState, type ReactNode } from "react";
2
- import { KeyboardAvoidingView, View } from "react-native";
1
+ import { useContext, useState, type ReactNode } from "react";
2
+ import { Platform, Pressable, Text, View, useWindowDimensions } from "react-native";
3
3
  import { useSafeAreaInsets } from "react-native-safe-area-context";
4
- import { IconArrowUp, IconPlayerStopFilled } from "@tabler/icons-react-native";
4
+ import { IconArrowUp, IconChevronDown, IconPlayerStopFilled } from "@tabler/icons-react-native";
5
5
  import { cn } from "../cn";
6
- import { fieldStyle, fieldTextStyle, typeStyle } from "../controlStyles";
6
+ import { MIN_TOUCH_TARGET, fieldStyle, fieldTextStyle, sizeStyle, touchSlop, typeStyle } from "../controlStyles";
7
+ import { withAlpha } from "../palette";
8
+ import { tokens } from "../tokens";
7
9
  import { Input as FieldInput } from "../primitives/input";
10
+ import { COMPOSER_INPUT_ID, KeyboardAvoidingView, KeyboardLiftContext } from "../primitives/keyboard-parts";
11
+ import { usePressFeedback } from "../motion";
8
12
  import { useControllableState } from "../primitives/use-controllable-state";
9
13
  import { useLargerText } from "../theme";
10
14
  import { useScheme } from "../useScheme";
11
- import { ChatIconButton, type ChatIconButtonProps } from "./icon-button";
15
+ import { ChatIconButton, type ChatIconButtonProps, type ChatIconButtonSize } from "./icon-button";
12
16
 
13
17
  /** The `ai` SDK chat status the web `PromptInputSubmit` takes. */
14
18
  export type ChatStatus = "ready" | "submitted" | "streaming" | "error";
@@ -24,6 +28,8 @@ export type PromptInputSubmitProps = {
24
28
  onStop?: () => void;
25
29
  /** Whether there is something to send. Default `true`. */
26
30
  canSubmit?: boolean;
31
+ /** Default `sm` (32). `md` is 36, the `pill` composer's send button. */
32
+ size?: ChatIconButtonSize;
27
33
  testID?: string;
28
34
  };
29
35
 
@@ -34,16 +40,17 @@ export type PromptInputSubmitProps = {
34
40
 
35
41
  /**
36
42
  * Send, or stop while the AI answers: the web `default` `icon-sm` button
37
- * (32, round, slop to 44). Send is an up arrow on the primary fill, and the
38
- * `secondary` fill, disabled, with nothing to send. While `status` is
39
- * `submitted` or `streaming` it is a filled square labelled "Stop" that calls
40
- * `onStop` (disabled without one).
43
+ * (32, round, slop to 44). Send is an up arrow on the primary fill, faded by
44
+ * the disabled `Button` with nothing to send, as the web send button. While
45
+ * `status` is `submitted` or `streaming` it is a filled square labelled
46
+ * "Stop" that calls `onStop` (disabled without one).
41
47
  */
42
48
  export function PromptInputSubmit({
43
49
  status,
44
50
  onSubmit,
45
51
  onStop,
46
52
  canSubmit = true,
53
+ size,
47
54
  testID,
48
55
  }: PromptInputSubmitProps) {
49
56
  if (isGenerating(status)) {
@@ -52,6 +59,7 @@ export function PromptInputSubmit({
52
59
  icon={IconPlayerStopFilled}
53
60
  label="Stop"
54
61
  variant="primary"
62
+ size={size}
55
63
  onPress={onStop}
56
64
  disabled={!onStop}
57
65
  testID={testID}
@@ -62,7 +70,8 @@ export function PromptInputSubmit({
62
70
  <ChatIconButton
63
71
  icon={IconArrowUp}
64
72
  label="Send"
65
- variant={canSubmit ? "primary" : "secondary"}
73
+ variant="primary"
74
+ size={size}
66
75
  onPress={onSubmit}
67
76
  disabled={!canSubmit}
68
77
  testID={testID}
@@ -77,6 +86,141 @@ export function PromptInputButton(props: PromptInputButtonProps) {
77
86
  return <ChatIconButton variant="ghost" {...props} />;
78
87
  }
79
88
 
89
+ /**
90
+ * The web `ModelPicker` trigger (`model-picker.tsx` `TRIGGER_CLASS`), the
91
+ * chip a composer footer holds: `h-7 gap-1.5 rounded-md px-2`, a 14 icon, the
92
+ * suffix at `/70` after " · ", and a 12 chevron at `opacity-60`. The label is
93
+ * `text-sm` (14), the phone floor for tappable text (the web is `text-xs`).
94
+ */
95
+ export const PROMPT_INPUT_CHIP = {
96
+ height: 28,
97
+ paddingX: 8,
98
+ gap: 6,
99
+ radius: tokens.radius.md,
100
+ iconSize: 14,
101
+ chevronSize: 12,
102
+ chevronOpacity: 0.6,
103
+ suffixOpacity: 0.7,
104
+ /** The narrowest label worth drawing: about two characters and the ellipsis at `text-sm` (2em). */
105
+ labelMinWidth: 28,
106
+ /** `size="touch"`: 36 on screen, 4 slop above and below, so a 44 bar holds its whole target. */
107
+ touchHeight: 36,
108
+ } as const;
109
+
110
+ export type PromptInputChipProps = {
111
+ /** Before the label: a `PROMPT_INPUT_CHIP.iconSize` (14) mark, such as `ProviderIcon` or a Tabler icon. */
112
+ icon?: ReactNode;
113
+ label: string;
114
+ /** After the label, " · " between, at 70% of `color`: the web trigger suffix (the reasoning level). */
115
+ suffix?: string;
116
+ /** After the label in medium weight with tabular figures, such as a saved count. */
117
+ count?: number;
118
+ /** The chevron after the label. Default `true`. */
119
+ chevron?: boolean;
120
+ /** Label weight. Default `medium`, the web trigger; a quieter footer passes `normal`. */
121
+ weight?: "normal" | "medium";
122
+ /** Text, chevron and Tabler icon colour. Default `tokens.color.muted`, the web `text-muted-foreground`. */
123
+ color?: string;
124
+ /** Default: the label, then the suffix and count. */
125
+ accessibilityLabel?: string;
126
+ /** Whether the menu it opens is open, for VoiceOver and TalkBack. */
127
+ expanded?: boolean;
128
+ disabled?: boolean;
129
+ /**
130
+ * Default `sm`, the web trigger: 28 high, slop to 44. `touch` has no web
131
+ * twin: 36 high, for a phone bar that is 44 itself, so the slop stays
132
+ * inside the bar and never reaches the field above.
133
+ */
134
+ size?: "sm" | "touch";
135
+ onPress?: () => void;
136
+ testID?: string;
137
+ };
138
+
139
+ /**
140
+ * A chip for the composer `footer`, the web `ModelPicker` trigger: 28 high on
141
+ * screen (36 with `size="touch"`), slop to 44 above and below. The label shrinks and truncates, so a
142
+ * long model name gives up width to the chips beside it, as the web
143
+ * `max-sm:truncate`; the icon and chevron keep their size. Below
144
+ * `labelMinWidth` (scaled by the system font size) the label hides, so a
145
+ * squeezed chip is its icon and chevron, as the web at 390, not a sliver of a
146
+ * letter. Disabled dims to 50%, as the web `disabled:opacity-50`.
147
+ */
148
+ export function PromptInputChip({
149
+ icon,
150
+ label,
151
+ suffix,
152
+ count,
153
+ chevron = true,
154
+ weight = "medium",
155
+ color,
156
+ accessibilityLabel,
157
+ expanded,
158
+ disabled = false,
159
+ size = "sm",
160
+ onPress,
161
+ testID,
162
+ }: PromptInputChipProps) {
163
+ const height = size === "touch" ? PROMPT_INPUT_CHIP.touchHeight : PROMPT_INPUT_CHIP.height;
164
+ const scheme = useScheme();
165
+ const pressed = usePressFeedback();
166
+ const { fontScale } = useWindowDimensions();
167
+ // Whether the label slot is wide enough to read. Hiding it only sets the
168
+ // opacity, so the layout and the next `onLayout` width stay the same: the
169
+ // state changes once per threshold crossing, never in a layout loop, and a
170
+ // wider slot later shows the label again.
171
+ const [readable, setReadable] = useState(true);
172
+ const tint = color ?? tokens.color.muted[scheme];
173
+ const text = [sizeStyle("text-sm"), { color: tint }];
174
+ const name = accessibilityLabel ?? [label, suffix, count].filter((part) => part !== undefined).join(", ");
175
+ return (
176
+ <Pressable
177
+ testID={testID}
178
+ accessibilityRole="button"
179
+ accessibilityLabel={name}
180
+ accessibilityState={{ disabled, expanded }}
181
+ disabled={disabled}
182
+ onPress={onPress}
183
+ // Slop only above and below: chips sit side by side, so the width stays theirs.
184
+ hitSlop={touchSlop(MIN_TOUCH_TARGET, height)}
185
+ className={cn("min-w-0 shrink flex-row items-center", pressed, disabled && "opacity-50")}
186
+ // `minHeight`, so a larger system font size can still grow it.
187
+ style={{
188
+ minHeight: height,
189
+ paddingHorizontal: PROMPT_INPUT_CHIP.paddingX,
190
+ gap: PROMPT_INPUT_CHIP.gap,
191
+ borderRadius: PROMPT_INPUT_CHIP.radius,
192
+ }}
193
+ >
194
+ {/* Held whole: in a squeezed chip only the label gives up width. */}
195
+ {icon ? <View className="shrink-0">{icon}</View> : null}
196
+ <Text
197
+ numberOfLines={1}
198
+ onLayout={(event) =>
199
+ setReadable(event.nativeEvent.layout.width >= PROMPT_INPUT_CHIP.labelMinWidth * Math.max(1, fontScale))
200
+ }
201
+ className={cn("min-w-0 shrink text-sm", weight === "medium" ? "font-instrument-sans-medium" : "font-instrument-sans")}
202
+ style={[...text, weight === "medium" ? typeStyle("medium") : null, readable ? null : { opacity: 0 }]}
203
+ >
204
+ {label}
205
+ {suffix ? <Text style={{ color: withAlpha(tint, PROMPT_INPUT_CHIP.suffixOpacity) }}>{` · ${suffix}`}</Text> : null}
206
+ </Text>
207
+ {count !== undefined ? (
208
+ <Text
209
+ className="font-instrument-sans-medium text-sm"
210
+ style={[...text, typeStyle("medium"), { fontVariant: ["tabular-nums"] }]}
211
+ >
212
+ {count}
213
+ </Text>
214
+ ) : null}
215
+ {chevron ? (
216
+ <View style={{ opacity: PROMPT_INPUT_CHIP.chevronOpacity }}>
217
+ <IconChevronDown size={PROMPT_INPUT_CHIP.chevronSize} color={tint} />
218
+ </View>
219
+ ) : null}
220
+ </Pressable>
221
+ );
222
+ }
223
+
80
224
  export type PromptInputProps = {
81
225
  value?: string;
82
226
  defaultValue?: string;
@@ -99,6 +243,17 @@ export type PromptInputProps = {
99
243
  header?: ReactNode;
100
244
  /** Before the send button, such as `PromptInputButton`s. */
101
245
  toolbar?: ReactNode;
246
+ /**
247
+ * Under the field, inside the keyboard lift and above the bottom inset: a
248
+ * row of `PromptInputChip`s, as the bar under Eva's web composer.
249
+ */
250
+ footer?: ReactNode;
251
+ /**
252
+ * Right above the field, the field's width and 6 clear of it, inside the
253
+ * keyboard lift: a `PromptInputMenu`, the web `@` / `/` picker. Pass it only
254
+ * while the picker is open.
255
+ */
256
+ menu?: ReactNode;
102
257
  /**
103
258
  * `stacked` (default): the field over a footer of tools and send, the web
104
259
  * `PromptInput`. `inline`: one row, as the web `Composer layout="inline"`
@@ -106,12 +261,19 @@ export type PromptInputProps = {
106
261
  * field with `p-1.5`, 44 high on one line.
107
262
  */
108
263
  layout?: "stacked" | "inline";
264
+ /**
265
+ * `inline` only. `rounded` (default): the web `Composer` field, radius 20.
266
+ * `pill`: Eva's web chat composer (`h-12 rounded-full py-1`): 48 high on one
267
+ * line, a 36 send button and a `text-sm` (14 / 20) field 36 high; it keeps
268
+ * radius 24 as it grows. Give the + a filled `variant="secondary"`, as Eva.
269
+ */
270
+ shape?: "rounded" | "pill";
109
271
  /** `inline` only: before the field, such as a + (attach) `PromptInputButton`. */
110
272
  leading?: ReactNode;
111
273
  disabled?: boolean;
112
274
  /** Pad the bottom safe-area inset (home indicator). Default `true`; turn it off when a tab bar below already does. */
113
275
  safeBottom?: boolean;
114
- /** Height of anything above the screen's own top, such as a navigation header, for the `KeyboardAvoidingView`. Default 0. */
276
+ /** Height of anything above the screen's own top, such as a navigation header, for the keyboard lift. Default 0. */
115
277
  keyboardVerticalOffset?: number;
116
278
  className?: string;
117
279
  testID?: string;
@@ -120,16 +282,33 @@ export type PromptInputProps = {
120
282
  /** The web `Composer` inline radius, `rounded-[1.25rem]`. */
121
283
  const INLINE_RADIUS = 20;
122
284
 
123
- /** The field grows to this height, then scrolls: about 6 lines of `text-base` (24 each, with `py-2.5`), and about 8 of `text-xl` with larger text. */
285
+ /**
286
+ * Eva's `pill` composer: 48 high on one line (border 1, then 5 around a 36
287
+ * field: the web `h-12 py-1` centres the 36 field and send button in 38),
288
+ * `pl-1.5` / `pr-1.5`, and radius 24 (`rounded-full` at 48). Its placeholder
289
+ * is the muted colour at 90% (`data-empty:before:text-muted-foreground/90`).
290
+ */
291
+ const PILL = { radius: 24, paddingY: 5, paddingX: 6, lineHeight: 36, placeholderAlpha: 0.9 } as const;
292
+
293
+ /** The field grows to this height, then scrolls: about 6 lines of 24 (with `py-2.5`), and about 8 of `text-xl` with larger text. */
124
294
  const MAX_FIELD_HEIGHT = { regular: 160, larger: 240 } as const;
125
295
 
296
+ /**
297
+ * React Native Web draws a multiline field as a `textarea` with the HTML
298
+ * default of 2 rows, and the auto-grow keeps that height. One row there; on
299
+ * native, `numberOfLines` would lock the line count on Android, so none.
300
+ */
301
+ const ONE_ROW = Platform.OS === "web" ? 1 : undefined;
302
+
126
303
  /**
127
304
  * The chat composer: the web `PromptInput` field (`rounded-field border
128
305
  * bg-field-background`, `fieldStyle`) with an optional `header` row, a
129
306
  * multiline field that grows from one line, and a footer of `toolbar` tools
130
307
  * and the send / stop button. It sits in a `KeyboardAvoidingView` (padding,
131
- * on iOS and Android) and pads the safe-area insets, so put it last in a
132
- * full-height column, under the `Conversation`.
308
+ * on iOS and Android; frame by frame with `KeyboardControllerProvider`) and
309
+ * pads the safe-area insets, so put it last in a full-height column, under the
310
+ * `Conversation`. Inside a part that lifts it already (`ConversationDock`
311
+ * with `KeyboardControllerProvider`, `ChatThread`) it adds no lift of its own.
133
312
  */
134
313
  export function PromptInput({
135
314
  value,
@@ -143,7 +322,10 @@ export function PromptInput({
143
322
  replyTo,
144
323
  header,
145
324
  toolbar,
325
+ footer,
326
+ menu,
146
327
  layout = "stacked",
328
+ shape = "rounded",
147
329
  leading,
148
330
  disabled = false,
149
331
  safeBottom = true,
@@ -154,6 +336,7 @@ export function PromptInput({
154
336
  const scheme = useScheme();
155
337
  const largerText = useLargerText();
156
338
  const insets = useSafeAreaInsets();
339
+ const lifted = useContext(KeyboardLiftContext);
157
340
  const [text, setText] = useControllableState({ value, defaultValue, onChange: onChangeText });
158
341
  const message = text.trim();
159
342
  const canSubmit = message.length > 0 && !disabled && !isGenerating(status);
@@ -164,6 +347,21 @@ export function PromptInput({
164
347
  const maxHeight = largerText ? MAX_FIELD_HEIGHT.larger : MAX_FIELD_HEIGHT.regular;
165
348
  const growStyle = contentHeight > 0 ? { height: Math.min(contentHeight, maxHeight) } : null;
166
349
  const scrollEnabled = contentHeight > maxHeight;
350
+ const pill = layout === "inline" && shape === "pill";
351
+ // Inline: the web `Composer` `text-sm leading-6` (Eva's pill: `leading-5`).
352
+ // Stacked: the web `PromptInput` `Textarea`, `text-base` below `sm`. Larger text is `text-xl`.
353
+ const fieldText = largerText
354
+ ? sizeStyle("text-xl")
355
+ : layout === "inline"
356
+ ? { fontSize: 14, lineHeight: pill ? 20 : 24 }
357
+ : sizeStyle("text-base");
358
+
359
+ // An empty field drops its measured height and is one line again: on the web
360
+ // a `textarea` only reports a taller height, never a shorter one.
361
+ const changeText = (next: string) => {
362
+ setText(next);
363
+ if (next === "") setContentHeight(0);
364
+ };
167
365
 
168
366
  const submit = () => {
169
367
  if (!canSubmit) return;
@@ -172,76 +370,50 @@ export function PromptInput({
172
370
  setContentHeight(0);
173
371
  };
174
372
 
175
- return (
176
- <KeyboardAvoidingView
177
- behavior="padding"
178
- // The keyboard covers the home indicator, so the inset padded below is
179
- // taken off the lift: the composer sits right on the keyboard.
180
- keyboardVerticalOffset={keyboardVerticalOffset - bottom}
373
+ const composer = (
374
+ <View
375
+ testID={testID}
376
+ className={cn("pt-2", className)}
377
+ style={{ paddingBottom: bottom + 8, paddingLeft: insets.left + 12, paddingRight: insets.right + 12 }}
181
378
  >
182
- <View
183
- testID={testID}
184
- className={cn("pt-2", className)}
185
- style={{ paddingBottom: bottom + 8, paddingLeft: insets.left + 12, paddingRight: insets.right + 12 }}
186
- >
187
- {layout === "inline" ? (
188
- // Web `Composer` inline: `rounded-[1.25rem]` and a `p-1.5` row, items at the bottom as the field grows.
189
- <View className="border" style={[fieldStyle(scheme), { borderRadius: INLINE_RADIUS }]}>
190
- {replyTo ? <View className="px-1.5 pt-1.5">{replyTo}</View> : null}
191
- {header ? <View className="flex-row flex-wrap gap-1 px-1.5 pt-1.5">{header}</View> : null}
192
- <View className="flex-row items-end gap-1 p-1.5">
193
- {leading ? <View className="shrink-0 flex-row items-center gap-1">{leading}</View> : null}
194
- <FieldInput
195
- multiline
196
- value={text}
197
- onChangeText={setText}
198
- placeholder={placeholder}
199
- accessibilityLabel={accessibilityLabel}
200
- editable={!disabled}
201
- textAlignVertical="center"
202
- scrollEnabled={scrollEnabled}
203
- onContentSizeChange={(e) => setContentHeight(e.nativeEvent.contentSize.height)}
204
- className={cn(
205
- "min-h-8 flex-1 border-0 px-2 py-1 text-base font-instrument-sans",
206
- largerText && "min-h-11 py-2 text-xl font-instrument-sans-medium",
207
- )}
208
- style={[
209
- fieldTextStyle(scheme),
210
- largerText ? typeStyle("medium") : null,
211
- {
212
- backgroundColor: "transparent",
213
- maxHeight,
214
- ...growStyle,
215
- },
216
- ]}
217
- />
218
- <View className="shrink-0 flex-row items-center gap-1">
219
- {toolbar}
220
- <PromptInputSubmit status={status} onSubmit={submit} onStop={onStop} canSubmit={canSubmit} />
379
+ {/* The web panel picker's 6 gap (`POPUP_GAP`) above the field. */}
380
+ {menu ? <View className="mb-1.5">{menu}</View> : null}
381
+ {layout === "inline" ? (
382
+ // Web `Composer` inline: `rounded-[1.25rem]` and a `p-1.5` row, items at the bottom as the field grows.
383
+ <View className="border" style={[fieldStyle(scheme), { borderRadius: pill ? PILL.radius : INLINE_RADIUS }]}>
384
+ {replyTo ? <View className="px-1.5 pt-1.5">{replyTo}</View> : null}
385
+ {header ? <View className="flex-row flex-wrap gap-1 px-1.5 pt-1.5">{header}</View> : null}
386
+ <View
387
+ className={cn("flex-row items-end gap-1", !pill && "p-1.5")}
388
+ style={pill ? { paddingVertical: PILL.paddingY, paddingHorizontal: PILL.paddingX } : null}
389
+ >
390
+ {leading ? (
391
+ // In the pill, centred on the 36 line, as the web `items-center` row.
392
+ <View className="shrink-0 flex-row items-center gap-1" style={pill ? { minHeight: PILL.lineHeight } : null}>
393
+ {leading}
221
394
  </View>
222
- </View>
223
- </View>
224
- ) : (
225
- <View className="border" style={fieldStyle(scheme)}>
226
- {replyTo ? <View className="px-2 pt-2">{replyTo}</View> : null}
227
- {header ? <View className="flex-row flex-wrap gap-1 px-2 pt-2">{header}</View> : null}
228
- {/* The `Textarea` field without its `numberOfLines` (default 4), which on Android sets the line count: this one starts at one line and grows. */}
395
+ ) : null}
229
396
  <FieldInput
397
+ nativeID={COMPOSER_INPUT_ID}
230
398
  multiline
399
+ numberOfLines={ONE_ROW}
231
400
  value={text}
232
- onChangeText={setText}
401
+ onChangeText={changeText}
233
402
  placeholder={placeholder}
403
+ placeholderTextColor={pill ? withAlpha(tokens.color.muted[scheme], PILL.placeholderAlpha) : undefined}
234
404
  accessibilityLabel={accessibilityLabel}
235
405
  editable={!disabled}
236
- textAlignVertical="top"
406
+ textAlignVertical="center"
237
407
  scrollEnabled={scrollEnabled}
238
408
  onContentSizeChange={(e) => setContentHeight(e.nativeEvent.contentSize.height)}
239
409
  className={cn(
240
- "min-h-11 border-0 px-3.5 py-2.5 text-base font-instrument-sans",
241
- largerText && "min-h-14 px-4 py-3 text-xl font-instrument-sans-medium",
410
+ "flex-1 border-0 text-sm font-instrument-sans",
411
+ pill ? "min-h-9 px-1 py-2" : "min-h-8 px-2 py-1",
412
+ largerText && "min-h-11 py-2 text-xl font-instrument-sans-medium",
242
413
  )}
243
414
  style={[
244
415
  fieldTextStyle(scheme),
416
+ fieldText,
245
417
  largerText ? typeStyle("medium") : null,
246
418
  {
247
419
  backgroundColor: "transparent",
@@ -250,13 +422,67 @@ export function PromptInput({
250
422
  },
251
423
  ]}
252
424
  />
253
- <View className="flex-row items-center justify-between gap-1 px-2 pb-2">
254
- <View className="min-w-0 shrink flex-row items-center gap-1">{toolbar}</View>
255
- <PromptInputSubmit status={status} onSubmit={submit} onStop={onStop} canSubmit={canSubmit} />
425
+ <View className="shrink-0 flex-row items-center gap-1" style={pill ? { minHeight: PILL.lineHeight } : null}>
426
+ {toolbar}
427
+ <PromptInputSubmit
428
+ status={status}
429
+ onSubmit={submit}
430
+ onStop={onStop}
431
+ canSubmit={canSubmit}
432
+ size={pill ? "md" : undefined}
433
+ />
256
434
  </View>
257
435
  </View>
258
- )}
259
- </View>
436
+ </View>
437
+ ) : (
438
+ <View className="border" style={fieldStyle(scheme)}>
439
+ {replyTo ? <View className="px-2 pt-2">{replyTo}</View> : null}
440
+ {header ? <View className="flex-row flex-wrap gap-1 px-2 pt-2">{header}</View> : null}
441
+ {/* The `Textarea` field without its `numberOfLines` (default 4), which on Android sets the line count: this one starts at one line and grows. */}
442
+ <FieldInput
443
+ nativeID={COMPOSER_INPUT_ID}
444
+ multiline
445
+ numberOfLines={ONE_ROW}
446
+ value={text}
447
+ onChangeText={changeText}
448
+ placeholder={placeholder}
449
+ accessibilityLabel={accessibilityLabel}
450
+ editable={!disabled}
451
+ textAlignVertical="top"
452
+ scrollEnabled={scrollEnabled}
453
+ onContentSizeChange={(e) => setContentHeight(e.nativeEvent.contentSize.height)}
454
+ className={cn(
455
+ "min-h-11 border-0 px-3.5 py-2.5 text-base font-instrument-sans",
456
+ largerText && "min-h-14 px-4 py-3 text-xl font-instrument-sans-medium",
457
+ )}
458
+ style={[
459
+ fieldTextStyle(scheme),
460
+ fieldText,
461
+ largerText ? typeStyle("medium") : null,
462
+ {
463
+ backgroundColor: "transparent",
464
+ maxHeight,
465
+ ...growStyle,
466
+ },
467
+ ]}
468
+ />
469
+ <View className="flex-row items-center justify-between gap-1 px-2 pb-2">
470
+ <View className="min-w-0 shrink flex-row items-center gap-1">{toolbar}</View>
471
+ <PromptInputSubmit status={status} onSubmit={submit} onStop={onStop} canSubmit={canSubmit} />
472
+ </View>
473
+ </View>
474
+ )}
475
+ {footer}
476
+ </View>
477
+ );
478
+ if (lifted) return composer;
479
+ return (
480
+ <KeyboardAvoidingView
481
+ // The keyboard covers the home indicator, so the inset padded below is
482
+ // taken off the lift: the composer sits right on the keyboard.
483
+ keyboardVerticalOffset={keyboardVerticalOffset - bottom}
484
+ >
485
+ {composer}
260
486
  </KeyboardAvoidingView>
261
487
  );
262
488
  }
@@ -54,18 +54,16 @@ export function Shimmer({
54
54
  useEffect(() => {
55
55
  if (reduceMotion) {
56
56
  cancelAnimation(opacity);
57
- opacity.value = 1;
57
+ opacity.set(1);
58
58
  return;
59
59
  }
60
- opacity.value = withRepeat(
61
- withTiming(SHIMMER_LOW_OPACITY, { duration: (duration * 1000) / 2 }),
62
- -1,
63
- true,
60
+ opacity.set(
61
+ withRepeat(withTiming(SHIMMER_LOW_OPACITY, { duration: (duration * 1000) / 2 }), -1, true),
64
62
  );
65
63
  return () => cancelAnimation(opacity);
66
64
  }, [opacity, reduceMotion, duration]);
67
65
 
68
- const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
66
+ const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.get() }));
69
67
  const textSize = chatTextSize(size, largerText);
70
68
 
71
69
  return (