@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.
- package/README.md +82 -19
- package/package.json +36 -3
- package/src/BottomSheetContainer.tsx +2 -2
- package/src/BulkActionBar.tsx +240 -105
- package/src/Button.tsx +11 -7
- package/src/ConfirmDialog.tsx +25 -35
- package/src/Dialog.tsx +47 -35
- package/src/DividedList.tsx +11 -7
- package/src/DropdownMenu.tsx +22 -8
- package/src/FloatingSurface.tsx +5 -2
- package/src/FloatingTabBar.tsx +13 -22
- package/src/HeaderIconButton.tsx +4 -3
- package/src/Input.tsx +2 -2
- package/src/LoadingState.tsx +132 -0
- package/src/PageTitle.tsx +9 -2
- package/src/Pager.tsx +62 -0
- package/src/PasswordInput.tsx +1 -1
- package/src/PillTabs.tsx +144 -34
- package/src/Progress.tsx +9 -7
- package/src/Select.tsx +4 -4
- package/src/Sheet.tsx +29 -22
- package/src/SheetRow.tsx +27 -9
- package/src/Skeleton.tsx +91 -9
- package/src/SurfaceRow.tsx +111 -0
- package/src/SwipeableRow.tsx +43 -0
- package/src/Switch.tsx +6 -4
- package/src/Tabs.tsx +68 -13
- package/src/Toast.tsx +107 -65
- package/src/ai/activity-step-detail.tsx +1 -3
- package/src/ai/activity-step-label.ts +981 -0
- package/src/ai/activity-step-types.ts +107 -0
- package/src/ai/activity-tasks-utils.ts +199 -0
- package/src/ai/activity-timeline.ts +45 -0
- package/src/ai/attachments.tsx +3 -1
- package/src/ai/card-button.tsx +49 -0
- package/src/ai/chat-text.ts +13 -7
- package/src/ai/chat-thread.tsx +34 -27
- package/src/ai/choice-question.tsx +154 -35
- package/src/ai/code-block.tsx +189 -49
- package/src/ai/collapsible.tsx +19 -13
- package/src/ai/conversation.tsx +390 -57
- package/src/ai/icon-button.tsx +41 -6
- package/src/ai/index.ts +52 -1
- package/src/ai/message-action-sheet.tsx +168 -0
- package/src/ai/message-quote.tsx +98 -0
- package/src/ai/message.tsx +226 -31
- package/src/ai/prompt-input-menu.tsx +197 -0
- package/src/ai/prompt-input.tsx +308 -73
- package/src/ai/reply-quote-chip.tsx +90 -0
- package/src/ai/shimmer.tsx +4 -6
- package/src/ai/subagent-cards.tsx +209 -0
- package/src/ai/tool-approval-card.tsx +162 -0
- package/src/ai/trace.tsx +54 -17
- package/src/ai/unified-diff.tsx +182 -0
- package/src/auth/GoogleSignInButton.tsx +6 -3
- package/src/auth/SignInScreen.tsx +11 -2
- package/src/brand/ProviderIcon.tsx +136 -0
- package/src/brand/index.ts +2 -0
- package/src/configureTheme.ts +39 -11
- package/src/controlStyles.ts +46 -30
- package/src/entries/gesture-handler.ts +4 -0
- package/src/entries/keyboard-controller.ts +4 -0
- package/src/entries/markdown.ts +1 -1
- package/src/entries/primitives.ts +24 -3
- package/src/entries/theme.ts +2 -1
- package/src/entries/video.ts +4 -0
- package/src/entries/webview.ts +6 -0
- package/src/floating.ts +25 -4
- package/src/haptics.ts +30 -4
- package/src/highlight.ts +302 -1
- package/src/index.ts +27 -5
- package/src/loadingGrid.ts +75 -0
- package/src/markdown/Markdown.tsx +234 -32
- package/src/markdown/MarkdownTable.tsx +188 -0
- package/src/markdown/tableText.ts +58 -0
- package/src/marketing/UpgradeToProCard.tsx +4 -3
- package/src/media/ImageGallery.tsx +224 -0
- package/src/media/MediaCard.tsx +100 -0
- package/src/media/imageGrid.ts +51 -0
- package/src/motion.ts +33 -0
- package/src/pagerSwipe.ts +102 -0
- package/src/palette.ts +8 -1
- package/src/primitives/button.tsx +4 -1
- package/src/primitives/keyboard-controller.tsx +106 -0
- package/src/primitives/keyboard-parts.tsx +104 -0
- package/src/primitives/overlay.tsx +133 -45
- package/src/primitives/pager-gesture.tsx +131 -0
- package/src/primitives/sheet-drag.tsx +305 -0
- package/src/primitives/sheet-gesture.tsx +181 -0
- package/src/primitives/swipe-row-gesture.tsx +290 -0
- package/src/primitives/toast-drag-gesture.tsx +92 -0
- package/src/primitives/web-click-guard.ts +50 -0
- package/src/relativeTime.ts +94 -0
- package/src/router/RouterStackPageHeader.tsx +2 -2
- package/src/settings/SettingsEmptyState.tsx +20 -10
- package/src/settings/SettingsField.tsx +11 -5
- package/src/settings/SettingsSection.tsx +8 -5
- package/src/settings/SettingsToggleRow.tsx +55 -22
- package/src/settings/relaxedSizeStyle.ts +11 -0
- package/src/sheetDrag.ts +67 -0
- package/src/swipeRow.ts +169 -0
- package/src/tabBarRoutes.ts +18 -0
- package/src/tabIndicator.ts +71 -0
- package/src/tailwind-preset.cjs +13 -6
- package/src/tailwind-preset.d.cts +5 -0
- package/src/theme.tsx +6 -2
- package/src/toastDrag.ts +58 -0
- package/src/typography.ts +20 -3
- package/src/useScheme.ts +10 -1
- package/src/video/InlineVideo.tsx +91 -0
- package/src/webview/HtmlView.tsx +91 -0
- package/src/webview/HtmlView.web.tsx +21 -0
- package/src/webview/htmlView.ts +45 -0
package/src/ai/prompt-input.tsx
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
-
import { useState, type ReactNode } from "react";
|
|
2
|
-
import {
|
|
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,
|
|
38
|
-
* `
|
|
39
|
-
* `submitted` or `streaming` it is a filled square labelled
|
|
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=
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
126
|
-
* full-height column, under the
|
|
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
|
-
|
|
169
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
<View
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
-
|
|
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={
|
|
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="
|
|
406
|
+
textAlignVertical="center"
|
|
228
407
|
scrollEnabled={scrollEnabled}
|
|
229
408
|
onContentSizeChange={(e) => setContentHeight(e.nativeEvent.contentSize.height)}
|
|
230
409
|
className={cn(
|
|
231
|
-
"
|
|
232
|
-
|
|
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
|
|
245
|
-
|
|
246
|
-
<PromptInputSubmit
|
|
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
|
-
|
|
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
|
+
}
|
package/src/ai/shimmer.tsx
CHANGED
|
@@ -54,18 +54,16 @@ export function Shimmer({
|
|
|
54
54
|
useEffect(() => {
|
|
55
55
|
if (reduceMotion) {
|
|
56
56
|
cancelAnimation(opacity);
|
|
57
|
-
opacity.
|
|
57
|
+
opacity.set(1);
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
60
|
-
opacity.
|
|
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.
|
|
66
|
+
const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.get() }));
|
|
69
67
|
const textSize = chatTextSize(size, largerText);
|
|
70
68
|
|
|
71
69
|
return (
|