@vvedantb/native-ui 0.12.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +82 -19
  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/card-button.tsx +49 -0
  36. package/src/ai/chat-text.ts +13 -7
  37. package/src/ai/chat-thread.tsx +34 -27
  38. package/src/ai/choice-question.tsx +154 -35
  39. package/src/ai/code-block.tsx +189 -49
  40. package/src/ai/collapsible.tsx +19 -13
  41. package/src/ai/conversation.tsx +390 -57
  42. package/src/ai/icon-button.tsx +41 -6
  43. package/src/ai/index.ts +52 -1
  44. package/src/ai/message-action-sheet.tsx +168 -0
  45. package/src/ai/message-quote.tsx +98 -0
  46. package/src/ai/message.tsx +226 -31
  47. package/src/ai/prompt-input-menu.tsx +197 -0
  48. package/src/ai/prompt-input.tsx +308 -73
  49. package/src/ai/reply-quote-chip.tsx +90 -0
  50. package/src/ai/shimmer.tsx +4 -6
  51. package/src/ai/subagent-cards.tsx +209 -0
  52. package/src/ai/tool-approval-card.tsx +162 -0
  53. package/src/ai/trace.tsx +54 -17
  54. package/src/ai/unified-diff.tsx +182 -0
  55. package/src/auth/GoogleSignInButton.tsx +6 -3
  56. package/src/auth/SignInScreen.tsx +11 -2
  57. package/src/brand/ProviderIcon.tsx +136 -0
  58. package/src/brand/index.ts +2 -0
  59. package/src/configureTheme.ts +39 -11
  60. package/src/controlStyles.ts +46 -30
  61. package/src/entries/gesture-handler.ts +4 -0
  62. package/src/entries/keyboard-controller.ts +4 -0
  63. package/src/entries/markdown.ts +1 -1
  64. package/src/entries/primitives.ts +24 -3
  65. package/src/entries/theme.ts +2 -1
  66. package/src/entries/video.ts +4 -0
  67. package/src/entries/webview.ts +6 -0
  68. package/src/floating.ts +25 -4
  69. package/src/haptics.ts +30 -4
  70. package/src/highlight.ts +302 -1
  71. package/src/index.ts +27 -5
  72. package/src/loadingGrid.ts +75 -0
  73. package/src/markdown/Markdown.tsx +234 -32
  74. package/src/markdown/MarkdownTable.tsx +188 -0
  75. package/src/markdown/tableText.ts +58 -0
  76. package/src/marketing/UpgradeToProCard.tsx +4 -3
  77. package/src/media/ImageGallery.tsx +224 -0
  78. package/src/media/MediaCard.tsx +100 -0
  79. package/src/media/imageGrid.ts +51 -0
  80. package/src/motion.ts +33 -0
  81. package/src/pagerSwipe.ts +102 -0
  82. package/src/palette.ts +8 -1
  83. package/src/primitives/button.tsx +4 -1
  84. package/src/primitives/keyboard-controller.tsx +106 -0
  85. package/src/primitives/keyboard-parts.tsx +104 -0
  86. package/src/primitives/overlay.tsx +133 -45
  87. package/src/primitives/pager-gesture.tsx +131 -0
  88. package/src/primitives/sheet-drag.tsx +305 -0
  89. package/src/primitives/sheet-gesture.tsx +181 -0
  90. package/src/primitives/swipe-row-gesture.tsx +290 -0
  91. package/src/primitives/toast-drag-gesture.tsx +92 -0
  92. package/src/primitives/web-click-guard.ts +50 -0
  93. package/src/relativeTime.ts +94 -0
  94. package/src/router/RouterStackPageHeader.tsx +2 -2
  95. package/src/settings/SettingsEmptyState.tsx +20 -10
  96. package/src/settings/SettingsField.tsx +11 -5
  97. package/src/settings/SettingsSection.tsx +8 -5
  98. package/src/settings/SettingsToggleRow.tsx +55 -22
  99. package/src/settings/relaxedSizeStyle.ts +11 -0
  100. package/src/sheetDrag.ts +67 -0
  101. package/src/swipeRow.ts +169 -0
  102. package/src/tabBarRoutes.ts +18 -0
  103. package/src/tabIndicator.ts +71 -0
  104. package/src/tailwind-preset.cjs +13 -6
  105. package/src/tailwind-preset.d.cts +5 -0
  106. package/src/theme.tsx +6 -2
  107. package/src/toastDrag.ts +58 -0
  108. package/src/typography.ts +20 -3
  109. package/src/useScheme.ts +10 -1
  110. package/src/video/InlineVideo.tsx +91 -0
  111. package/src/webview/HtmlView.tsx +91 -0
  112. package/src/webview/HtmlView.web.tsx +21 -0
  113. 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;
@@ -89,10 +233,27 @@ export type PromptInputProps = {
89
233
  placeholder?: string;
90
234
  /** Names the field for VoiceOver and TalkBack. Default "Message". */
91
235
  accessibilityLabel?: string;
236
+ /**
237
+ * A pending reply above the field and above `header`: a `ReplyQuoteChip`.
238
+ * Clear it in the app with the chip's `onDismiss`. A `ReplyQuoteChip` as
239
+ * `header` works as well, beside any attachments.
240
+ */
241
+ replyTo?: ReactNode;
92
242
  /** Above the field, such as `Attachments`. */
93
243
  header?: ReactNode;
94
244
  /** Before the send button, such as `PromptInputButton`s. */
95
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;
96
257
  /**
97
258
  * `stacked` (default): the field over a footer of tools and send, the web
98
259
  * `PromptInput`. `inline`: one row, as the web `Composer layout="inline"`
@@ -100,12 +261,19 @@ export type PromptInputProps = {
100
261
  * field with `p-1.5`, 44 high on one line.
101
262
  */
102
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";
103
271
  /** `inline` only: before the field, such as a + (attach) `PromptInputButton`. */
104
272
  leading?: ReactNode;
105
273
  disabled?: boolean;
106
274
  /** Pad the bottom safe-area inset (home indicator). Default `true`; turn it off when a tab bar below already does. */
107
275
  safeBottom?: boolean;
108
- /** 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. */
109
277
  keyboardVerticalOffset?: number;
110
278
  className?: string;
111
279
  testID?: string;
@@ -114,16 +282,33 @@ export type PromptInputProps = {
114
282
  /** The web `Composer` inline radius, `rounded-[1.25rem]`. */
115
283
  const INLINE_RADIUS = 20;
116
284
 
117
- /** 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. */
118
294
  const MAX_FIELD_HEIGHT = { regular: 160, larger: 240 } as const;
119
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
+
120
303
  /**
121
304
  * The chat composer: the web `PromptInput` field (`rounded-field border
122
305
  * bg-field-background`, `fieldStyle`) with an optional `header` row, a
123
306
  * multiline field that grows from one line, and a footer of `toolbar` tools
124
307
  * and the send / stop button. It sits in a `KeyboardAvoidingView` (padding,
125
- * on iOS and Android) and pads the safe-area insets, so put it last in a
126
- * 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.
127
312
  */
128
313
  export function PromptInput({
129
314
  value,
@@ -134,9 +319,13 @@ export function PromptInput({
134
319
  onStop,
135
320
  placeholder = "What would you like to know?",
136
321
  accessibilityLabel = "Message",
322
+ replyTo,
137
323
  header,
138
324
  toolbar,
325
+ footer,
326
+ menu,
139
327
  layout = "stacked",
328
+ shape = "rounded",
140
329
  leading,
141
330
  disabled = false,
142
331
  safeBottom = true,
@@ -147,6 +336,7 @@ export function PromptInput({
147
336
  const scheme = useScheme();
148
337
  const largerText = useLargerText();
149
338
  const insets = useSafeAreaInsets();
339
+ const lifted = useContext(KeyboardLiftContext);
150
340
  const [text, setText] = useControllableState({ value, defaultValue, onChange: onChangeText });
151
341
  const message = text.trim();
152
342
  const canSubmit = message.length > 0 && !disabled && !isGenerating(status);
@@ -157,6 +347,21 @@ export function PromptInput({
157
347
  const maxHeight = largerText ? MAX_FIELD_HEIGHT.larger : MAX_FIELD_HEIGHT.regular;
158
348
  const growStyle = contentHeight > 0 ? { height: Math.min(contentHeight, maxHeight) } : null;
159
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
+ };
160
365
 
161
366
  const submit = () => {
162
367
  if (!canSubmit) return;
@@ -165,74 +370,50 @@ export function PromptInput({
165
370
  setContentHeight(0);
166
371
  };
167
372
 
168
- return (
169
- <KeyboardAvoidingView
170
- behavior="padding"
171
- // The keyboard covers the home indicator, so the inset padded below is
172
- // taken off the lift: the composer sits right on the keyboard.
173
- 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 }}
174
378
  >
175
- <View
176
- testID={testID}
177
- className={cn("pt-2", className)}
178
- style={{ paddingBottom: bottom + 8, paddingLeft: insets.left + 12, paddingRight: insets.right + 12 }}
179
- >
180
- {layout === "inline" ? (
181
- // Web `Composer` inline: `rounded-[1.25rem]` and a `p-1.5` row, items at the bottom as the field grows.
182
- <View className="border" style={[fieldStyle(scheme), { borderRadius: INLINE_RADIUS }]}>
183
- {header ? <View className="flex-row flex-wrap gap-1 px-1.5 pt-1.5">{header}</View> : null}
184
- <View className="flex-row items-end gap-1 p-1.5">
185
- {leading ? <View className="shrink-0 flex-row items-center gap-1">{leading}</View> : null}
186
- <FieldInput
187
- multiline
188
- value={text}
189
- onChangeText={setText}
190
- placeholder={placeholder}
191
- accessibilityLabel={accessibilityLabel}
192
- editable={!disabled}
193
- textAlignVertical="center"
194
- scrollEnabled={scrollEnabled}
195
- onContentSizeChange={(e) => setContentHeight(e.nativeEvent.contentSize.height)}
196
- className={cn(
197
- "min-h-8 flex-1 border-0 px-2 py-1 text-base font-instrument-sans",
198
- largerText && "min-h-11 py-2 text-xl font-instrument-sans-medium",
199
- )}
200
- style={[
201
- fieldTextStyle(scheme),
202
- largerText ? typeStyle("medium") : null,
203
- {
204
- backgroundColor: "transparent",
205
- maxHeight,
206
- ...growStyle,
207
- },
208
- ]}
209
- />
210
- <View className="shrink-0 flex-row items-center gap-1">
211
- {toolbar}
212
- <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}
213
394
  </View>
214
- </View>
215
- </View>
216
- ) : (
217
- <View className="border" style={fieldStyle(scheme)}>
218
- {header ? <View className="flex-row flex-wrap gap-1 px-2 pt-2">{header}</View> : null}
219
- {/* 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}
220
396
  <FieldInput
397
+ nativeID={COMPOSER_INPUT_ID}
221
398
  multiline
399
+ numberOfLines={ONE_ROW}
222
400
  value={text}
223
- onChangeText={setText}
401
+ onChangeText={changeText}
224
402
  placeholder={placeholder}
403
+ placeholderTextColor={pill ? withAlpha(tokens.color.muted[scheme], PILL.placeholderAlpha) : undefined}
225
404
  accessibilityLabel={accessibilityLabel}
226
405
  editable={!disabled}
227
- textAlignVertical="top"
406
+ textAlignVertical="center"
228
407
  scrollEnabled={scrollEnabled}
229
408
  onContentSizeChange={(e) => setContentHeight(e.nativeEvent.contentSize.height)}
230
409
  className={cn(
231
- "min-h-11 border-0 px-3.5 py-2.5 text-base font-instrument-sans",
232
- 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",
233
413
  )}
234
414
  style={[
235
415
  fieldTextStyle(scheme),
416
+ fieldText,
236
417
  largerText ? typeStyle("medium") : null,
237
418
  {
238
419
  backgroundColor: "transparent",
@@ -241,13 +422,67 @@ export function PromptInput({
241
422
  },
242
423
  ]}
243
424
  />
244
- <View className="flex-row items-center justify-between gap-1 px-2 pb-2">
245
- <View className="min-w-0 shrink flex-row items-center gap-1">{toolbar}</View>
246
- <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
+ />
247
434
  </View>
248
435
  </View>
249
- )}
250
- </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}
251
486
  </KeyboardAvoidingView>
252
487
  );
253
488
  }
@@ -0,0 +1,90 @@
1
+ import { Pressable, Text, View } from "react-native";
2
+ import { IconX } from "@tabler/icons-react-native";
3
+ import { cn } from "../cn";
4
+ import { MIN_TOUCH_TARGET, fieldStyle, sizeStyle, textStyle, touchSlop, typeStyle } from "../controlStyles";
5
+ import { palette } from "../palette";
6
+ import { useLargerText } from "../theme";
7
+ import { tokens } from "../tokens";
8
+ import { useScheme } from "../useScheme";
9
+ import { chatTextSize } from "./chat-text";
10
+
11
+ export type ReplyQuoteChipProps = {
12
+ /** Who wrote the message being replied to. */
13
+ author: string;
14
+ /** Its text. Shown on up to two lines. */
15
+ text: string;
16
+ /** Called when the X is pressed. Clear the reply in the app. */
17
+ onDismiss?: () => void;
18
+ /** Default "Replying to {author}". */
19
+ accessibilityLabel?: string;
20
+ /** Names the X button. Default "Dismiss reply quote". */
21
+ dismissLabel?: string;
22
+ className?: string;
23
+ testID?: string;
24
+ };
25
+
26
+ /**
27
+ * The chip above the composer while a reply is pending: a field-style
28
+ * surface with a primary accent bar, the author, up to two lines of the
29
+ * message and an X (44 point target). Pass it as `PromptInput` `replyTo`, or
30
+ * as `header`.
31
+ */
32
+ export function ReplyQuoteChip({
33
+ author,
34
+ text,
35
+ onDismiss,
36
+ accessibilityLabel,
37
+ dismissLabel = "Dismiss reply quote",
38
+ className,
39
+ testID,
40
+ }: ReplyQuoteChipProps) {
41
+ const scheme = useScheme();
42
+ const largerText = useLargerText();
43
+ const caption = chatTextSize("caption", largerText);
44
+ const body = chatTextSize("small", largerText);
45
+ return (
46
+ <View
47
+ testID={testID}
48
+ accessible
49
+ accessibilityLabel={accessibilityLabel ?? `Replying to ${author}`}
50
+ accessibilityHint={text}
51
+ className={cn("w-full flex-row items-center rounded-field border pl-3", className)}
52
+ style={[
53
+ fieldStyle(scheme),
54
+ { backgroundColor: tokens.color.surfaceSecondary[scheme] },
55
+ ]}
56
+ >
57
+ <View
58
+ className="mr-2.5 self-stretch rounded-full"
59
+ style={{ width: 3, marginVertical: 8, backgroundColor: palette.primary[scheme] }}
60
+ />
61
+ <View className="min-w-0 flex-1 py-2">
62
+ <Text
63
+ numberOfLines={1}
64
+ className={cn(caption, "font-instrument-sans-semibold")}
65
+ style={[sizeStyle(caption), typeStyle("semibold"), textStyle("muted", scheme)]}
66
+ >
67
+ {author}
68
+ </Text>
69
+ <Text
70
+ numberOfLines={2}
71
+ className={cn(body, "font-instrument-sans")}
72
+ style={[sizeStyle(body), textStyle("foreground", scheme)]}
73
+ >
74
+ {text}
75
+ </Text>
76
+ </View>
77
+ <Pressable
78
+ testID={testID ? `${testID}-dismiss` : undefined}
79
+ accessibilityRole="button"
80
+ accessibilityLabel={dismissLabel}
81
+ onPress={onDismiss}
82
+ hitSlop={touchSlop(MIN_TOUCH_TARGET)}
83
+ className="items-center justify-center"
84
+ style={{ width: MIN_TOUCH_TARGET, height: MIN_TOUCH_TARGET }}
85
+ >
86
+ <IconX size={largerText ? 20 : 16} color={tokens.color.muted[scheme]} />
87
+ </Pressable>
88
+ </View>
89
+ );
90
+ }
@@ -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 (