@vvedantb/native-ui 0.11.2 → 0.12.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.
- package/README.md +42 -5
- package/package.json +5 -1
- package/src/ArtifactList.tsx +38 -0
- package/src/DropdownMenu.tsx +35 -30
- package/src/FloatingSurface.tsx +2 -1
- package/src/PinnedBotTile.tsx +88 -0
- package/src/ScreenContainer.tsx +31 -3
- package/src/StackPageHeader.tsx +24 -2
- package/src/ai/attachments.tsx +47 -12
- package/src/ai/chat-jump-rail.tsx +38 -0
- package/src/ai/chat-thread.tsx +96 -0
- package/src/ai/index.ts +5 -0
- package/src/ai/prompt-input.tsx +16 -5
- package/src/auth/GoogleSignInButton.tsx +3 -3
- package/src/auth/SignInScreen.tsx +3 -3
- package/src/definiteSize.ts +12 -0
- package/src/entries/expo-router.ts +5 -0
- package/src/entries/layout.ts +7 -0
- package/src/index.ts +7 -0
- package/src/router/RouterStackPageHeader.tsx +25 -0
- package/src/settings/SettingsEmptyState.tsx +44 -0
package/README.md
CHANGED
|
@@ -21,6 +21,7 @@ Peers the app must already have (Expo SDK 57 / RN 0.76+):
|
|
|
21
21
|
|
|
22
22
|
Optional, only if you import those components:
|
|
23
23
|
|
|
24
|
+
- `@rn-primitives/portal` — `Select`. Mount one `<PortalHost />` from this package at the app root (for example in the root layout), outside any `Modal`. The package does not re-export it, so install it with `@rn-primitives/select`.
|
|
24
25
|
- `@gorhom/bottom-sheet` — `BottomSheetContainer`
|
|
25
26
|
- `@react-native-community/datetimepicker` and `dayjs` — `DateTimeField`
|
|
26
27
|
- `@clerk/clerk-expo`, `convex`, `expo-auth-session`, `expo-web-browser` and `expo-router` — `@vvedantb/native-ui/auth` (0.7.0). `expo-router` also for `@vvedantb/native-ui/expo-web-root`.
|
|
@@ -72,16 +73,17 @@ The root entry exports everything. It still imports `BottomSheetContainer` and `
|
|
|
72
73
|
| `@vvedantb/native-ui/toast` | `toast`, `Toaster`, `ToastOptions`, `ToastVariant` | none (from 0.3.12) |
|
|
73
74
|
| `@vvedantb/native-ui/theme` | `NativeUiProvider`, `useNativeUi`, `useLargerText`, `useAccentColor`, `DEFAULT_ACCENT`, `tokens`, `palette`, `withAlpha`, `configureTheme` (with the `ThemeConfig`, `ThemeColors` and `ThemePalette` types), `fontClass`, `cn`, `Colors` and the colour constants | none |
|
|
74
75
|
| `@vvedantb/native-ui/floating` | `FloatingSurface`, `GlassSheetBackground`, `LiquidGlass`, `GlassBlurProvider`, `GlassBlurTarget`, `FloatingFAB`, `useReduceTransparency`, the floating metrics and styles | `expo-blur` (loader) |
|
|
75
|
-
| `@vvedantb/native-ui/layout` | `ScreenContainer`, `SafeAreaContainer`, `PageTitle`, `PageHeader`, `StackPageHeader` (0.5.0) and their props types, `SectionLabel` (0.5.0), `SettingsSection`, `SettingsField`, `SettingsToggleRow`, `SettingsStack`, `MoreFromProjectV` and `FromProjectV` (0.8.0) | `expo-blur` (loader) |
|
|
76
|
+
| `@vvedantb/native-ui/layout` | `ScreenContainer`, `SafeAreaContainer`, `PageTitle`, `PageHeader`, `StackPageHeader` (0.5.0) and their props types, `SectionLabel` (0.5.0), `SettingsSection`, `SettingsField`, `SettingsToggleRow`, `SettingsStack`, `MoreFromProjectV` and `FromProjectV` (0.8.0), `ScreenContainerProps`, `SettingsEmptyState`, `PinnedBotTile`, `BotPinGrid`, `ArtifactList` (0.12.0). `ScreenContainer` takes `refreshing`, `onRefresh` and `refreshControl` (0.12.0). `StackPageHeader` takes `centre` (0.12.0) | `expo-blur` (loader) |
|
|
76
77
|
| `@vvedantb/native-ui/marketing` | `UpgradeToProCard`, `PricingModal` and the pricing types | none |
|
|
77
78
|
| `@vvedantb/native-ui/ambient` | `AmbientBackground` | `expo-linear-gradient` (loader) |
|
|
78
79
|
| `@vvedantb/native-ui/blur`, `/haptics` | the loaders | `expo-blur`, `expo-haptics` (loaders) |
|
|
79
80
|
| `@vvedantb/native-ui/push` | `useExpoPushRegistration`, `getExpoPushToken`, `showPushesInForeground`, the three loaders and the types (0.9.0). Pass `register` from your backend (for `@vvedantb/server`: `useMutation(api.push.register)`) and route taps in `onNotificationTap` | `expo-notifications`, `expo-constants`, `expo-device` (loaders) |
|
|
80
81
|
| `@vvedantb/native-ui/bottom-sheet` | `BottomSheetContainer` | `@gorhom/bottom-sheet` (import), `expo-blur` (loader) |
|
|
81
82
|
| `@vvedantb/native-ui/date-time` | `DateTimeField`, `DateTimeFieldProps` (0.5.0) | `@react-native-community/datetimepicker`, `dayjs` (import) |
|
|
82
|
-
| `@vvedantb/native-ui/ai` | The AI chat parts (from 0.4.0): `Conversation`, `Message`, `Reasoning`, `AgentTrace`, `ChainOfThought`, `ActivityStep`, `PromptInput`, `Suggestions`, `Sources`, `FileChip`, `Attachments`, `CodeBlock`, `ResponseText`, `Shimmer`, `Collapsible`, `useStreamingDisclosure` and their parts. See [AI chat](#ai-chat-040). Not on the root entry. | none |
|
|
83
|
+
| `@vvedantb/native-ui/ai` | The AI chat parts (from 0.4.0): `Conversation`, `Message`, `Reasoning`, `AgentTrace`, `ChainOfThought`, `ActivityStep`, `PromptInput`, `Suggestions`, `Sources`, `FileChip`, `Attachments`, `CodeBlock`, `ResponseText`, `Shimmer`, `Collapsible`, `useStreamingDisclosure` and their parts. See [AI chat](#ai-chat-040). Not on the root entry.. From 0.12.0: `ChatThread`, `UIMessage`, `ChatJumpRail` and the `Attachment` props `onPress` and `accessibilityLabel` | none |
|
|
83
84
|
| `@vvedantb/native-ui/ai-gallery` | `AiChatGallery`, `AiChatSamples` (from 0.4.0) | none |
|
|
84
85
|
| `@vvedantb/native-ui/auth` | From 0.7.0: `AuthProviders`, `SignInScreen`, `SignUpScreen`, `GoogleSignInButton`, `useGoogleSignIn`, `AuthScreen`, `OrDivider`, `FieldError`, `authErrorMessage`, `SecondFactorScreen`, `useSecondFactor`, `submitTotpSecondFactor`, `submitSecondFactor`, `prepareSecondFactorIfNeeded`, `prepareFirstFactorIfNeeded`, `submitFirstFactor`, `pickFirstFactorStrategy`, `TOTP_MESSAGES` and their props types. See [Auth](#auth-070). Not on the root entry. | `@clerk/clerk-expo`, `convex`, `expo-auth-session`, `expo-web-browser`, `expo-router` (import) |
|
|
86
|
+
| `@vvedantb/native-ui/expo-router` | From 0.12.0: `RouterStackPageHeader`, `RouterStackPageHeaderProps`, `goBackOrReplace`. Not on the root, `/layout` or `/page-title` entries. | `expo-router` (import) |
|
|
85
87
|
| `@vvedantb/native-ui/expo-web-root` | From 0.7.0: `ExpoWebRoot`, `ExpoWebRootProps`, `ExpoWebRootViewport`, `EXPO_WEB_VIEWPORT`, `expoWebBackgroundCss`. Not on the root entry. | `expo-router` (import of `expo-router/html`) |
|
|
86
88
|
| `@vvedantb/native-ui/tailwind-preset` | From 0.7.0, CommonJS for `tailwind.config.js`: `nativeUiPreset`, `nativeUiContent`, `cssVar`, `hslVar`, `rgbAlpha`, `cssVariables`, `schemeColors` and a default preset. See [Expo / NativeWind](#expo--nativewind). | `nativewind` (`require`, when installed) |
|
|
87
89
|
|
|
@@ -426,6 +428,41 @@ Components and props that the Verve, vibot and Velth apps need. They follow the
|
|
|
426
428
|
</AmbientBackground>
|
|
427
429
|
```
|
|
428
430
|
|
|
431
|
+
### 0.12.0 additions
|
|
432
|
+
|
|
433
|
+
```tsx
|
|
434
|
+
// Typed routes: `afterAuthHref`, `signUpHref` and `signInHref` take expo-router's `Href`,
|
|
435
|
+
// and `replaceOptions` takes `Parameters<Router["replace"]>[1]`.
|
|
436
|
+
<SignInScreen scheme="app" afterAuthHref="/(app)/bots" signUpHref="/sign-up" replaceOptions={{ withAnchor: true }} />
|
|
437
|
+
|
|
438
|
+
// Router-aware back: router.canGoBack() ? router.back() : router.replace(fallbackHref).
|
|
439
|
+
import { RouterStackPageHeader } from "@vvedantb/native-ui/expo-router";
|
|
440
|
+
<RouterStackPageHeader title="Bot" fallbackHref="/bots" backTestID="screen-back" centre={<BotTitle />} />
|
|
441
|
+
|
|
442
|
+
// An attachment chip that opens its file. The remove button stays separate.
|
|
443
|
+
<Attachment data={file} onPress={() => open(file.url)} onRemove={() => remove(file.id)} />
|
|
444
|
+
|
|
445
|
+
// The chat column as one piece.
|
|
446
|
+
<ChatThread
|
|
447
|
+
messages={messages}
|
|
448
|
+
working={status === "streaming"}
|
|
449
|
+
renderMessage={(message, isStreaming) => <MessageItem message={message} streaming={isStreaming} />}
|
|
450
|
+
thinking={<Shimmer>Thinking</Shimmer>}
|
|
451
|
+
footer={<PromptInput onSubmit={send} status={status} />}
|
|
452
|
+
contentContainerClassName="px-4 py-4"
|
|
453
|
+
/>
|
|
454
|
+
|
|
455
|
+
// A two-across grid of pinned bots.
|
|
456
|
+
<BotPinGrid>
|
|
457
|
+
<PinnedBotTile title="Research" leading={<Avatar />} unread onPress={open} onLongPress={showActions} />
|
|
458
|
+
</BotPinGrid>
|
|
459
|
+
|
|
460
|
+
// Pull to refresh. `refreshing` is controlled. No control without `onRefresh` or `refreshControl`.
|
|
461
|
+
<ScreenContainer refreshing={refreshing} onRefresh={reload}>{children}</ScreenContainer>
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
`StackPageHeader` `centre` replaces the centre `PageTitle` (for example a 20 px avatar beside a name). `title` stays the accessible name and the fallback; `description` still shows under it. The two sides keep the same width and the centre shrinks and truncates. `RouterStackPageHeader` takes the `StackPageHeader` props without `onBack`, plus `fallbackHref` (default `"/"`). `ChatThread` shows `thinking` right after the last user message while `working` and marks the last assistant message as streaming. A `PromptInput` footer keeps its own keyboard lift; the outer `KeyboardAvoidingView` moves it clear first, so the lift is not doubled. `SettingsEmptyState` is a compact empty note for the inside of a `SettingsSection`; `ArtifactList` lists artifacts as pressable rows; `ChatJumpRail` is a horizontal rail of chips that jump between messages.
|
|
465
|
+
|
|
429
466
|
`Text` from `@vvedantb/native-ui/primitives` is the variant text (`h1`–`h4`, `p`, `lead`, `large`, `small`, `muted`, `code`, `blockquote`). It is not on the root entry. Unlike the components, it takes its colour from NativeWind classes (`text-foreground`, `text-muted-foreground`); where the app defines `foreground` with a nested `dark` key, give it `style={{ color: tokens.color.foreground[scheme] }}` in dark mode.
|
|
430
467
|
|
|
431
468
|
## AI chat (0.4.0)
|
|
@@ -482,8 +519,8 @@ import {
|
|
|
482
519
|
| `AgentTrace`, `AgentTraceLine`, `AgentTraceIcon`, `AgentTraceTitle`, `AgentTraceDescription`, `AgentTraceCollapsible*` | One step per line, with an optional fold behind a rail. `AgentTraceTitle shimmer` pulses a running step. Spread `useStreamingDisclosure(isStreaming)` on `AgentTraceCollapsible` to open it while a step streams. |
|
|
483
520
|
| `ActivityStep` | One tool step as a trace line: icon, label, a spinner while `running`, a check when `complete`, a warning on `error`, and a description such as `` `Worked for ${formatElapsed(12)}` ``. |
|
|
484
521
|
| `ChainOfThought` and its parts | Simplified: header, steps with a rail, search-result chips. |
|
|
485
|
-
| `PromptInput`, `PromptInputButton`, `PromptInputSubmit` | The web field surface with a `header` slot (attachments), a field, a `toolbar` slot and the send button. The field grows from one line to 160 high (about 6 lines), then scrolls. Send is primary with text and disabled without. While `status` is `submitted` or `streaming` it is a "Stop" square that calls `onStop`. It sits in a `KeyboardAvoidingView` (padding, on iOS and Android) and pads the safe-area insets: put it last in a full-height column. Under a navigation header, pass the header height as `keyboardVerticalOffset`; above a tab bar, pass `safeBottom={false}`. |
|
|
486
|
-
| `PromptInput layout="inline"` | One row, as the web `Composer layout="inline"` (Eva's chat pill): `leading` (such as a + button), the field (one line to start, then it grows), `toolbar`, then send, in a 20-radius field with `p-1.5`. 44 high on one line. |
|
|
522
|
+
| `PromptInput`, `PromptInputButton`, `PromptInputSubmit` | The web field surface with a `header` slot (attachments), a field, a `toolbar` slot and the send button. The field grows from one line to 160 high (about 6 lines), then scrolls (auto-grow in both layouts from 0.12.1; send resets the height). Send is primary with text and disabled without. While `status` is `submitted` or `streaming` it is a "Stop" square that calls `onStop`. It sits in a `KeyboardAvoidingView` (padding, on iOS and Android) and pads the safe-area insets: put it last in a full-height column. Under a navigation header, pass the header height as `keyboardVerticalOffset`; above a tab bar, pass `safeBottom={false}`. |
|
|
523
|
+
| `PromptInput layout="inline"` | One row, as the web `Composer layout="inline"` (Eva's chat pill): `leading` (such as a + button), the field (one line to start, then it grows from 0.12.1), `toolbar`, then send, in a 20-radius field with `p-1.5`. 44 high on one line. |
|
|
487
524
|
| `Suggestions`, `Suggestion` | A horizontal row of round chips (or `orientation="vertical"`). `onPress` gets the suggestion text. |
|
|
488
525
|
| `Sources`, `SourcesTrigger`, `SourcesContent`, `Source` | A fold of link rows. A row opens its address with `Linking.openURL`, or hands it to `onPress`. |
|
|
489
526
|
| `FileChip`, `Attachments`, `Attachment` | A monospace file chip (a button with `onOpenFile`), and attachment chips with a media icon or image thumbnail and a labelled remove button. No hover cards and no grid previews. |
|
|
@@ -507,7 +544,7 @@ Every control has a role, an accessible name and a 44 pt target. Folds report `e
|
|
|
507
544
|
|
|
508
545
|
## DropdownMenu (Unreleased)
|
|
509
546
|
|
|
510
|
-
A small menu anchored to its trigger, the native counterpart of the web `DropdownMenu`. On `/primitives` and the root.
|
|
547
|
+
A small menu anchored to its trigger, the native counterpart of the web `DropdownMenu`. On `/primitives` and the root. From 0.12.1 the backdrop and menu sit in a full-size `View` inside the `Modal`, so the menu stays above tab bars and glass layers.
|
|
511
548
|
|
|
512
549
|
```tsx
|
|
513
550
|
<DropdownMenu>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/native-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Shared React Native / Expo UI primitives. Floating surfaces, forms, overlays, toasts, settings chrome, tab bar, AI chat parts, the family app marks, a NativeWind preset, the Expo web root and Clerk + Convex auth screens.",
|
|
6
6
|
"type": "module",
|
|
@@ -109,6 +109,10 @@
|
|
|
109
109
|
"types": "./src/entries/markdown.ts",
|
|
110
110
|
"default": "./src/entries/markdown.ts"
|
|
111
111
|
},
|
|
112
|
+
"./expo-router": {
|
|
113
|
+
"types": "./src/entries/expo-router.ts",
|
|
114
|
+
"default": "./src/entries/expo-router.ts"
|
|
115
|
+
},
|
|
112
116
|
"./expo-web-root": {
|
|
113
117
|
"types": "./src/entries/expo-web-root.ts",
|
|
114
118
|
"default": "./src/entries/expo-web-root.ts"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { View } from "react-native";
|
|
3
|
+
import { DividedList } from "./DividedList";
|
|
4
|
+
import { ListRow } from "./ListRow";
|
|
5
|
+
|
|
6
|
+
export type ArtifactRow = { id: string; title: string; description?: string };
|
|
7
|
+
|
|
8
|
+
export type ArtifactListProps = {
|
|
9
|
+
rows: ArtifactRow[];
|
|
10
|
+
/** Called with the `id` of the pressed row. */
|
|
11
|
+
onOpen: (id: string) => void;
|
|
12
|
+
/** Shown when `rows` is empty, such as an `EmptyState`. Nothing without it. */
|
|
13
|
+
empty?: ReactNode;
|
|
14
|
+
className?: string;
|
|
15
|
+
testID?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Artifacts as pressable `ListRow`s under hairlines (`DividedList`): the title
|
|
20
|
+
* and an optional description line. A row is a button named by its title.
|
|
21
|
+
* `empty` stands in for an empty list.
|
|
22
|
+
*/
|
|
23
|
+
export function ArtifactList({ rows, onOpen, empty, className, testID }: ArtifactListProps) {
|
|
24
|
+
if (rows.length === 0) return empty != null ? <View testID={testID}>{empty}</View> : null;
|
|
25
|
+
return (
|
|
26
|
+
<DividedList className={className} testID={testID}>
|
|
27
|
+
{rows.map((row) => (
|
|
28
|
+
<ListRow
|
|
29
|
+
key={row.id}
|
|
30
|
+
testID={`artifact-${row.id}`}
|
|
31
|
+
title={row.title}
|
|
32
|
+
meta={row.description}
|
|
33
|
+
onPress={() => onOpen(row.id)}
|
|
34
|
+
/>
|
|
35
|
+
))}
|
|
36
|
+
</DividedList>
|
|
37
|
+
);
|
|
38
|
+
}
|
package/src/DropdownMenu.tsx
CHANGED
|
@@ -217,36 +217,41 @@ export function DropdownMenuContent({
|
|
|
217
217
|
statusBarTranslucent
|
|
218
218
|
onRequestClose={close}
|
|
219
219
|
>
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
{
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
220
|
+
{/* A full-size host, as `OverlayModal` has: without it the backdrop and the
|
|
221
|
+
menu are bare Modal children with no box to size or hit-test against,
|
|
222
|
+
and the menu can stack under tab bars and glass layers. */}
|
|
223
|
+
<View style={{ flex: 1 }} pointerEvents="box-none">
|
|
224
|
+
<Pressable
|
|
225
|
+
testID="DropdownMenu-backdrop"
|
|
226
|
+
accessibilityRole="button"
|
|
227
|
+
accessibilityLabel="Close menu"
|
|
228
|
+
onPress={close}
|
|
229
|
+
style={StyleSheet.absoluteFill}
|
|
230
|
+
/>
|
|
231
|
+
<View
|
|
232
|
+
accessibilityRole="menu"
|
|
233
|
+
accessibilityViewIsModal
|
|
234
|
+
onAccessibilityEscape={close}
|
|
235
|
+
style={{
|
|
236
|
+
position: "absolute",
|
|
237
|
+
...position,
|
|
238
|
+
minWidth: DROPDOWN_MENU.minWidth,
|
|
239
|
+
maxWidth: window.width - insets.left - insets.right - 2 * DROPDOWN_MENU.edge,
|
|
240
|
+
}}
|
|
241
|
+
>
|
|
242
|
+
<FloatingSurface blur={false} radius={DROPDOWN_MENU.radius} style={{ maxHeight: position.maxHeight }}>
|
|
243
|
+
<ScrollView
|
|
244
|
+
bounces={false}
|
|
245
|
+
showsVerticalScrollIndicator={false}
|
|
246
|
+
onContentSizeChange={(_width, height) => setContentHeight(height)}
|
|
247
|
+
contentContainerStyle={{ padding: DROPDOWN_MENU.padding }}
|
|
248
|
+
>
|
|
249
|
+
<View className={cn(className)} style={style} {...props}>
|
|
250
|
+
{children}
|
|
251
|
+
</View>
|
|
252
|
+
</ScrollView>
|
|
253
|
+
</FloatingSurface>
|
|
254
|
+
</View>
|
|
250
255
|
</View>
|
|
251
256
|
</Modal>
|
|
252
257
|
);
|
package/src/FloatingSurface.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import { Platform, StyleSheet, View, type ViewProps } from "react-native";
|
|
|
2
2
|
import { useColorScheme } from "nativewind";
|
|
3
3
|
import { glassMode, loadBlur } from "./blur";
|
|
4
4
|
import { cn } from "./cn";
|
|
5
|
+
import { hasDefiniteHeight } from "./definiteSize";
|
|
5
6
|
import {
|
|
6
7
|
floatingFill,
|
|
7
8
|
floatingHairline,
|
|
@@ -65,7 +66,7 @@ export function FloatingSurface({
|
|
|
65
66
|
backgroundColor: mode === "blur" ? "transparent" : fill,
|
|
66
67
|
},
|
|
67
68
|
Platform.OS === "ios" ? styles.continuous : null,
|
|
68
|
-
style
|
|
69
|
+
hasDefiniteHeight(style) ? styles.fill : null,
|
|
69
70
|
]}
|
|
70
71
|
>
|
|
71
72
|
{mode === "blur" && BlurView ? (
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { Children, type ReactNode } from "react";
|
|
2
|
+
import { Pressable, Text, View, type ViewProps } from "react-native";
|
|
3
|
+
import { cn } from "./cn";
|
|
4
|
+
import { cardStyle, textStyle, typeStyle } from "./controlStyles";
|
|
5
|
+
import { StatusDot } from "./StatusDot";
|
|
6
|
+
import { Spinner } from "./Spinner";
|
|
7
|
+
import { useLargerText } from "./theme";
|
|
8
|
+
import { useScheme } from "./useScheme";
|
|
9
|
+
|
|
10
|
+
export type PinnedBotTileProps = {
|
|
11
|
+
/** The bot name, under the avatar. Up to two lines. */
|
|
12
|
+
title: string;
|
|
13
|
+
/** The avatar, above the name. */
|
|
14
|
+
leading: ReactNode;
|
|
15
|
+
/** Shows an unread dot at the top start and adds "unread" to the label. */
|
|
16
|
+
unread?: boolean;
|
|
17
|
+
/** Shows a spinner at the top end and adds "generating" to the label. It can show with the unread dot. */
|
|
18
|
+
generating?: boolean;
|
|
19
|
+
onPress: () => void;
|
|
20
|
+
/** Opens actions on a long press. */
|
|
21
|
+
onLongPress?: () => void;
|
|
22
|
+
testID?: string;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A large pinned-bot tile: the avatar above the name on a `Card` fill, an
|
|
27
|
+
* unread dot or a generating spinner at the top end. At least 112 high, so it
|
|
28
|
+
* is a comfortable touch target in a `BotPinGrid`. The tile is one button named
|
|
29
|
+
* by the title, plus "unread" or "generating".
|
|
30
|
+
*/
|
|
31
|
+
export function PinnedBotTile({ title, leading, unread = false, generating = false, onPress, onLongPress, testID }: PinnedBotTileProps) {
|
|
32
|
+
const scheme = useScheme();
|
|
33
|
+
const largerText = useLargerText();
|
|
34
|
+
const state = [unread ? "unread" : null, generating ? "generating" : null].filter(Boolean).join(", ");
|
|
35
|
+
return (
|
|
36
|
+
<Pressable
|
|
37
|
+
testID={testID}
|
|
38
|
+
accessibilityRole="button"
|
|
39
|
+
accessibilityLabel={state ? `${title}, ${state}` : title}
|
|
40
|
+
onPress={onPress}
|
|
41
|
+
onLongPress={onLongPress}
|
|
42
|
+
className={cn("min-w-0 items-center justify-center gap-2 px-3 py-4 active:opacity-70", largerText ? "min-h-36" : "min-h-28")}
|
|
43
|
+
style={cardStyle(scheme)}
|
|
44
|
+
>
|
|
45
|
+
{leading}
|
|
46
|
+
<Text
|
|
47
|
+
numberOfLines={2}
|
|
48
|
+
className={cn("text-center font-instrument-sans-semibold", largerText ? "text-lg" : "text-base")}
|
|
49
|
+
style={[textStyle("foreground", scheme), typeStyle("semibold")]}
|
|
50
|
+
>
|
|
51
|
+
{title}
|
|
52
|
+
</Text>
|
|
53
|
+
{unread ? (
|
|
54
|
+
<View className="absolute left-3 top-3" testID={testID ? `${testID}-unread` : undefined}>
|
|
55
|
+
<StatusDot tone="active" size="md" />
|
|
56
|
+
</View>
|
|
57
|
+
) : null}
|
|
58
|
+
{generating ? (
|
|
59
|
+
<View className="absolute right-3 top-3" testID={testID ? `${testID}-generating` : undefined}>
|
|
60
|
+
<Spinner size="sm" decorative />
|
|
61
|
+
</View>
|
|
62
|
+
) : null}
|
|
63
|
+
</Pressable>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type BotPinGridProps = ViewProps & {
|
|
68
|
+
/** `PinnedBotTile`s. */
|
|
69
|
+
children?: ReactNode;
|
|
70
|
+
className?: string;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Lays `PinnedBotTile`s out two across, wrapping to new rows, with a 12 gap. A
|
|
75
|
+
* lone last tile keeps half the width. Each child gets a half-width cell.
|
|
76
|
+
*/
|
|
77
|
+
export function BotPinGrid({ children, className, ...props }: BotPinGridProps) {
|
|
78
|
+
const cells = Children.toArray(children);
|
|
79
|
+
return (
|
|
80
|
+
<View className={cn("flex-row flex-wrap gap-3", className)} {...props}>
|
|
81
|
+
{cells.map((child, index) => (
|
|
82
|
+
<View key={(child as { key?: string | null }).key ?? index} className="min-w-0" style={{ flexBasis: "47%", flexGrow: 1, maxWidth: "50%" }}>
|
|
83
|
+
{child}
|
|
84
|
+
</View>
|
|
85
|
+
))}
|
|
86
|
+
</View>
|
|
87
|
+
);
|
|
88
|
+
}
|
package/src/ScreenContainer.tsx
CHANGED
|
@@ -1,25 +1,52 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
RefreshControl,
|
|
3
|
+
ScrollView,
|
|
4
|
+
View,
|
|
5
|
+
type RefreshControlProps,
|
|
6
|
+
type ViewProps,
|
|
7
|
+
} from "react-native";
|
|
2
8
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
3
|
-
import { useLargerText } from "./theme";
|
|
9
|
+
import { useAccentColor, useLargerText } from "./theme";
|
|
4
10
|
import { tabBarReserve } from "./floating";
|
|
5
11
|
|
|
6
|
-
interface ScreenContainerProps extends ViewProps {
|
|
12
|
+
export interface ScreenContainerProps extends ViewProps {
|
|
7
13
|
children: React.ReactNode;
|
|
8
14
|
reserveTabBar?: boolean;
|
|
9
15
|
footer?: React.ReactNode;
|
|
16
|
+
/** Shows the pull-to-refresh spinner while true. Controlled: no timer, no minimum duration. */
|
|
17
|
+
refreshing?: boolean;
|
|
18
|
+
/** Called on pull. May return a promise. Adds a `RefreshControl` tinted with the accent colour. */
|
|
19
|
+
onRefresh?: () => void | Promise<void>;
|
|
20
|
+
/** Full control. Takes precedence over `refreshing` and `onRefresh`. */
|
|
21
|
+
refreshControl?: React.ReactElement<RefreshControlProps>;
|
|
10
22
|
}
|
|
11
23
|
|
|
12
24
|
export function ScreenContainer({
|
|
13
25
|
children,
|
|
14
26
|
reserveTabBar = false,
|
|
15
27
|
footer,
|
|
28
|
+
refreshing = false,
|
|
29
|
+
onRefresh,
|
|
30
|
+
refreshControl,
|
|
16
31
|
...props
|
|
17
32
|
}: ScreenContainerProps) {
|
|
18
33
|
const insets = useSafeAreaInsets();
|
|
19
34
|
const largerText = useLargerText();
|
|
35
|
+
const accent = useAccentColor();
|
|
20
36
|
const bottomPad = reserveTabBar
|
|
21
37
|
? tabBarReserve(largerText, insets.bottom)
|
|
22
38
|
: 40;
|
|
39
|
+
// No control unless the app asks for one.
|
|
40
|
+
const control =
|
|
41
|
+
refreshControl ??
|
|
42
|
+
(onRefresh ? (
|
|
43
|
+
<RefreshControl
|
|
44
|
+
refreshing={refreshing}
|
|
45
|
+
onRefresh={() => void onRefresh()}
|
|
46
|
+
tintColor={accent}
|
|
47
|
+
colors={[accent]}
|
|
48
|
+
/>
|
|
49
|
+
) : undefined);
|
|
23
50
|
|
|
24
51
|
return (
|
|
25
52
|
<View className="flex-1 bg-transparent" {...props}>
|
|
@@ -27,6 +54,7 @@ export function ScreenContainer({
|
|
|
27
54
|
showsVerticalScrollIndicator={false}
|
|
28
55
|
className="flex-1"
|
|
29
56
|
contentContainerStyle={{ paddingBottom: bottomPad }}
|
|
57
|
+
refreshControl={control}
|
|
30
58
|
>
|
|
31
59
|
<View className="flex-col gap-4 p-4">{children}</View>
|
|
32
60
|
{footer ? <View className="pt-4">{footer}</View> : null}
|
package/src/StackPageHeader.tsx
CHANGED
|
@@ -11,7 +11,15 @@ import { tokens } from "./tokens";
|
|
|
11
11
|
import { useScheme } from "./useScheme";
|
|
12
12
|
|
|
13
13
|
export type StackPageHeaderProps = {
|
|
14
|
+
/** The accessible name of the header, and the centre text when `centre` is not set. */
|
|
14
15
|
title: string;
|
|
16
|
+
/**
|
|
17
|
+
* Custom content for the centre, such as a 20 px avatar beside a name. It
|
|
18
|
+
* replaces the `PageTitle`. `title` stays the accessible name. Wrap long
|
|
19
|
+
* text in a `PageTitle` (or `Text` with `numberOfLines`) so it can truncate:
|
|
20
|
+
* the centre shrinks (`min-w-0`) and clips what does not fit.
|
|
21
|
+
*/
|
|
22
|
+
centre?: ReactNode;
|
|
15
23
|
/** Muted line under the title, up to two lines. */
|
|
16
24
|
description?: string;
|
|
17
25
|
/** Shows a back `HeaderIconButton` at the start that calls this. No button without it. */
|
|
@@ -36,11 +44,14 @@ export type StackPageHeaderProps = {
|
|
|
36
44
|
* a centred `PageTitle` (Instrument Sans 600 at 20 / 25) with an optional
|
|
37
45
|
* muted description. The two sides are the same width, the wider of the back
|
|
38
46
|
* button and `headerRight`, so the title stays centred on the screen and a
|
|
39
|
-
* long one is cut off before it reaches them.
|
|
47
|
+
* long one is cut off before it reaches them. `centre` (0.12.0) replaces the
|
|
48
|
+
* `PageTitle` with custom content; `title` then names the header for
|
|
49
|
+
* VoiceOver and TalkBack. The row is at least 56 high
|
|
40
50
|
* below the top inset, as `PageHeader`, with the `px-4` screen padding.
|
|
41
51
|
*/
|
|
42
52
|
export function StackPageHeader({
|
|
43
53
|
title,
|
|
54
|
+
centre,
|
|
44
55
|
description,
|
|
45
56
|
onBack,
|
|
46
57
|
backLabel = "Back",
|
|
@@ -74,7 +85,18 @@ export function StackPageHeader({
|
|
|
74
85
|
) : null}
|
|
75
86
|
</View>
|
|
76
87
|
<View className="min-w-0 shrink items-center py-2">
|
|
77
|
-
|
|
88
|
+
{centre != null ? (
|
|
89
|
+
<View
|
|
90
|
+
accessible
|
|
91
|
+
accessibilityRole="header"
|
|
92
|
+
accessibilityLabel={title}
|
|
93
|
+
className="min-w-0 max-w-full shrink flex-row items-center justify-center"
|
|
94
|
+
>
|
|
95
|
+
{centre}
|
|
96
|
+
</View>
|
|
97
|
+
) : (
|
|
98
|
+
<PageTitle className="text-center">{title}</PageTitle>
|
|
99
|
+
)}
|
|
78
100
|
{description ? (
|
|
79
101
|
<Text
|
|
80
102
|
numberOfLines={2}
|
package/src/ai/attachments.tsx
CHANGED
|
@@ -88,18 +88,31 @@ export type AttachmentProps = {
|
|
|
88
88
|
data: AttachmentData;
|
|
89
89
|
/** Shows a remove button, labelled "Remove" and the attachment name. */
|
|
90
90
|
onRemove?: () => void;
|
|
91
|
+
/**
|
|
92
|
+
* Makes the chip a button, such as to open the file. The remove button stays a
|
|
93
|
+
* separate target and does not call this. Without it the chip is not pressable.
|
|
94
|
+
*/
|
|
95
|
+
onPress?: () => void;
|
|
96
|
+
/** Names the chip button, with `onPress`. Default the attachment name (`getAttachmentLabel`). */
|
|
97
|
+
accessibilityLabel?: string;
|
|
91
98
|
testID?: string;
|
|
92
99
|
};
|
|
93
100
|
|
|
101
|
+
/** The hit target of a pressable chip, in points, on each axis. */
|
|
102
|
+
export const ATTACHMENT_PRESS_TARGET = 48;
|
|
103
|
+
|
|
94
104
|
/** A remove cross of 20 in a 32 row: the slop brings it to 44. */
|
|
95
105
|
const REMOVE_SIZE = 20;
|
|
96
106
|
|
|
97
107
|
/**
|
|
98
108
|
* One attachment as a chip: a 20 thumbnail for an image with a `url`, else
|
|
99
109
|
* its media icon, then the name and an optional remove button. Flat, on the
|
|
100
|
-
* web `flatPanelSubtle` fill (`bg-surface-secondary`). No hover card.
|
|
110
|
+
* web `flatPanelSubtle` fill (`bg-surface-secondary`). No hover card. With
|
|
111
|
+
* `onPress` the thumbnail or icon and the name are one button with a 48 point
|
|
112
|
+
* hit target (the chip stays 32, or 44 with larger text, and `hitSlop` does the
|
|
113
|
+
* rest); the remove button is a separate target that never calls `onPress`.
|
|
101
114
|
*/
|
|
102
|
-
export function Attachment({ data, onRemove, testID }: AttachmentProps) {
|
|
115
|
+
export function Attachment({ data, onRemove, onPress, accessibilityLabel, testID }: AttachmentProps) {
|
|
103
116
|
const variant = useContext(AttachmentsContext);
|
|
104
117
|
const scheme = useScheme();
|
|
105
118
|
const largerText = useLargerText();
|
|
@@ -108,16 +121,8 @@ export function Attachment({ data, onRemove, testID }: AttachmentProps) {
|
|
|
108
121
|
const Icon = MEDIA_ICONS[category];
|
|
109
122
|
const iconSize = largerText ? 20 : 16;
|
|
110
123
|
const muted = tokens.color.muted[scheme];
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
testID={testID}
|
|
114
|
-
className={cn(
|
|
115
|
-
"min-w-0 flex-row items-center gap-1.5 px-2",
|
|
116
|
-
largerText ? "min-h-11" : "min-h-8",
|
|
117
|
-
variant === "list" ? "w-full" : "max-w-full",
|
|
118
|
-
)}
|
|
119
|
-
style={{ borderRadius: tokens.radius.sm, backgroundColor: tokens.color.surfaceSecondary[scheme] }}
|
|
120
|
-
>
|
|
124
|
+
const content = (
|
|
125
|
+
<>
|
|
121
126
|
{category === "image" && data.url ? (
|
|
122
127
|
<Image
|
|
123
128
|
source={{ uri: data.url }}
|
|
@@ -136,6 +141,36 @@ export function Attachment({ data, onRemove, testID }: AttachmentProps) {
|
|
|
136
141
|
>
|
|
137
142
|
{label}
|
|
138
143
|
</ResponseText>
|
|
144
|
+
</>
|
|
145
|
+
);
|
|
146
|
+
const chipHeight = largerText ? 44 : 32;
|
|
147
|
+
const slop = Math.max(0, Math.ceil((ATTACHMENT_PRESS_TARGET - chipHeight) / 2));
|
|
148
|
+
return (
|
|
149
|
+
<View
|
|
150
|
+
testID={testID}
|
|
151
|
+
className={cn(
|
|
152
|
+
"min-w-0 flex-row items-center gap-1.5 px-2",
|
|
153
|
+
largerText ? "min-h-11" : "min-h-8",
|
|
154
|
+
variant === "list" ? "w-full" : "max-w-full",
|
|
155
|
+
)}
|
|
156
|
+
style={{ borderRadius: tokens.radius.sm, backgroundColor: tokens.color.surfaceSecondary[scheme] }}
|
|
157
|
+
>
|
|
158
|
+
{onPress ? (
|
|
159
|
+
<Pressable
|
|
160
|
+
accessibilityRole="button"
|
|
161
|
+
accessibilityLabel={accessibilityLabel ?? label}
|
|
162
|
+
hitSlop={{ top: slop, bottom: slop, left: 0, right: 0 }}
|
|
163
|
+
onPress={onPress}
|
|
164
|
+
className={cn(
|
|
165
|
+
"min-w-0 flex-row items-center gap-1.5 self-stretch active:opacity-70",
|
|
166
|
+
variant === "list" ? "flex-1" : "shrink",
|
|
167
|
+
)}
|
|
168
|
+
>
|
|
169
|
+
{content}
|
|
170
|
+
</Pressable>
|
|
171
|
+
) : (
|
|
172
|
+
content
|
|
173
|
+
)}
|
|
139
174
|
{onRemove ? (
|
|
140
175
|
<Pressable
|
|
141
176
|
accessibilityRole="button"
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { ScrollView } from "react-native";
|
|
2
|
+
import { Chip } from "../Chip";
|
|
3
|
+
import { cn } from "../cn";
|
|
4
|
+
|
|
5
|
+
export type ChatJumpRailItem = { id: string; label: string };
|
|
6
|
+
|
|
7
|
+
export type ChatJumpRailProps = {
|
|
8
|
+
/** The messages to jump between, such as the user messages of a long thread. */
|
|
9
|
+
items: ChatJumpRailItem[];
|
|
10
|
+
/** Called with the `id` of the pressed item. */
|
|
11
|
+
onJump: (id: string) => void;
|
|
12
|
+
className?: string;
|
|
13
|
+
testID?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A rail to jump between messages (web `chat-jump-rail`): a horizontal row of
|
|
18
|
+
* chips, one per item. Each chip is a 48 high button named by its label. Draws
|
|
19
|
+
* nothing for an empty list.
|
|
20
|
+
*/
|
|
21
|
+
export function ChatJumpRail({ items, onJump, className, testID }: ChatJumpRailProps) {
|
|
22
|
+
if (items.length === 0) return null;
|
|
23
|
+
return (
|
|
24
|
+
<ScrollView
|
|
25
|
+
testID={testID}
|
|
26
|
+
horizontal
|
|
27
|
+
showsHorizontalScrollIndicator={false}
|
|
28
|
+
keyboardShouldPersistTaps="handled"
|
|
29
|
+
accessibilityRole="toolbar"
|
|
30
|
+
className={cn("grow-0", className)}
|
|
31
|
+
contentContainerClassName="gap-2 px-3 py-2"
|
|
32
|
+
>
|
|
33
|
+
{items.map((item) => (
|
|
34
|
+
<Chip key={item.id} label={item.label} selected={false} onPress={() => onJump(item.id)} testID={`jump-${item.id}`} />
|
|
35
|
+
))}
|
|
36
|
+
</ScrollView>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { KeyboardAvoidingView, View } from "react-native";
|
|
3
|
+
import { cn } from "../cn";
|
|
4
|
+
import {
|
|
5
|
+
Conversation,
|
|
6
|
+
ConversationContent,
|
|
7
|
+
ConversationScrollButton,
|
|
8
|
+
} from "./conversation";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The least `ChatThread` reads from a message: an id and a role. The `ai` SDK
|
|
12
|
+
* `UIMessage` fits it, so the package takes no `ai` dependency.
|
|
13
|
+
*/
|
|
14
|
+
export type UIMessage = {
|
|
15
|
+
id: string;
|
|
16
|
+
role: "system" | "user" | "assistant";
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type ChatThreadProps<M extends UIMessage = UIMessage> = {
|
|
20
|
+
messages: M[];
|
|
21
|
+
/** True while a reply is on its way. Marks the last assistant message as streaming and shows `thinking`. */
|
|
22
|
+
working: boolean;
|
|
23
|
+
/** Draws one message. `isStreaming` is true for the last assistant message while `working`. */
|
|
24
|
+
renderMessage: (message: M, isStreaming: boolean) => ReactNode;
|
|
25
|
+
/** A row after the last user message while `working`, such as a `Shimmer` "Thinking". */
|
|
26
|
+
thinking?: ReactNode;
|
|
27
|
+
/** Shown in place of the list when there are no messages. */
|
|
28
|
+
emptyState?: ReactNode;
|
|
29
|
+
/** Last in the column, lifted over the keyboard. A `PromptInput`. */
|
|
30
|
+
footer: ReactNode;
|
|
31
|
+
/** Merged into the `ConversationContent` column classes (`gap-4 px-3 py-3`), such as `px-4 py-4`. */
|
|
32
|
+
contentContainerClassName?: string;
|
|
33
|
+
/** Height of anything above the thread, such as a navigation header, for the keyboard lift. Default 0. */
|
|
34
|
+
keyboardVerticalOffset?: number;
|
|
35
|
+
className?: string;
|
|
36
|
+
testID?: string;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Index of the last message with `role`, or -1. A loop, not `findLastIndex` (Hermes). */
|
|
40
|
+
function lastIndexOfRole(messages: UIMessage[], role: UIMessage["role"]) {
|
|
41
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
42
|
+
if (messages[index]!.role === role) return index;
|
|
43
|
+
}
|
|
44
|
+
return -1;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The chat column as one piece: `Conversation`, `ConversationContent`, the
|
|
49
|
+
* messages, a `thinking` row right after the last user message while
|
|
50
|
+
* `working`, `ConversationScrollButton` and the `footer` last. The whole
|
|
51
|
+
* column sits in a `KeyboardAvoidingView` (`padding`, as `PromptInput`), which
|
|
52
|
+
* also lifts the footer on Android edge-to-edge, where the window does not
|
|
53
|
+
* resize. A `PromptInput` footer has its own `KeyboardAvoidingView`; the outer
|
|
54
|
+
* lift moves it clear of the keyboard, so it adds no second lift.
|
|
55
|
+
*/
|
|
56
|
+
export function ChatThread<M extends UIMessage = UIMessage>({
|
|
57
|
+
messages,
|
|
58
|
+
working,
|
|
59
|
+
renderMessage,
|
|
60
|
+
thinking,
|
|
61
|
+
emptyState,
|
|
62
|
+
footer,
|
|
63
|
+
contentContainerClassName,
|
|
64
|
+
keyboardVerticalOffset = 0,
|
|
65
|
+
className,
|
|
66
|
+
testID,
|
|
67
|
+
}: ChatThreadProps<M>) {
|
|
68
|
+
const lastUser = working ? lastIndexOfRole(messages, "user") : -1;
|
|
69
|
+
const streaming = working ? lastIndexOfRole(messages, "assistant") : -1;
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<KeyboardAvoidingView
|
|
73
|
+
testID={testID}
|
|
74
|
+
behavior="padding"
|
|
75
|
+
keyboardVerticalOffset={keyboardVerticalOffset}
|
|
76
|
+
className={cn("flex-1", className)}
|
|
77
|
+
>
|
|
78
|
+
<Conversation>
|
|
79
|
+
<ConversationContent contentContainerClassName={contentContainerClassName}>
|
|
80
|
+
{messages.length === 0
|
|
81
|
+
? emptyState
|
|
82
|
+
: messages.map((message, index) => (
|
|
83
|
+
<View key={message.id}>
|
|
84
|
+
{renderMessage(message, index === streaming)}
|
|
85
|
+
{index === lastUser && thinking != null ? (
|
|
86
|
+
<View className="mt-4">{thinking}</View>
|
|
87
|
+
) : null}
|
|
88
|
+
</View>
|
|
89
|
+
))}
|
|
90
|
+
</ConversationContent>
|
|
91
|
+
<ConversationScrollButton testID="chat-thread-scroll-bottom" />
|
|
92
|
+
</Conversation>
|
|
93
|
+
{footer}
|
|
94
|
+
</KeyboardAvoidingView>
|
|
95
|
+
);
|
|
96
|
+
}
|
package/src/ai/index.ts
CHANGED
|
@@ -115,6 +115,7 @@ export {
|
|
|
115
115
|
Attachment,
|
|
116
116
|
getMediaCategory,
|
|
117
117
|
getAttachmentLabel,
|
|
118
|
+
ATTACHMENT_PRESS_TARGET,
|
|
118
119
|
} from "./attachments";
|
|
119
120
|
export type {
|
|
120
121
|
AttachmentData,
|
|
@@ -123,6 +124,10 @@ export type {
|
|
|
123
124
|
AttachmentsProps,
|
|
124
125
|
AttachmentProps,
|
|
125
126
|
} from "./attachments";
|
|
127
|
+
export { ChatThread } from "./chat-thread";
|
|
128
|
+
export type { ChatThreadProps, UIMessage } from "./chat-thread";
|
|
129
|
+
export { ChatJumpRail } from "./chat-jump-rail";
|
|
130
|
+
export type { ChatJumpRailProps, ChatJumpRailItem } from "./chat-jump-rail";
|
|
126
131
|
export { CodeBlock, CodeBlockCopyButton } from "./code-block";
|
|
127
132
|
export type { CodeBlockProps, CodeBlockCopyButtonProps } from "./code-block";
|
|
128
133
|
export { ResponseText, ChatTextColorContext } from "./response-text";
|
package/src/ai/prompt-input.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
2
|
import { KeyboardAvoidingView, View } from "react-native";
|
|
3
3
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
4
4
|
import { IconArrowUp, IconPlayerStopFilled } from "@tabler/icons-react-native";
|
|
@@ -151,11 +151,18 @@ export function PromptInput({
|
|
|
151
151
|
const message = text.trim();
|
|
152
152
|
const canSubmit = message.length > 0 && !disabled && !isGenerating(status);
|
|
153
153
|
const bottom = safeBottom ? insets.bottom : 0;
|
|
154
|
+
// The field grows with its content up to `maxHeight`, then scrolls. A fixed
|
|
155
|
+
// `numberOfLines` would lock the line count on Android, so neither layout sets it.
|
|
156
|
+
const [contentHeight, setContentHeight] = useState(0);
|
|
157
|
+
const maxHeight = largerText ? MAX_FIELD_HEIGHT.larger : MAX_FIELD_HEIGHT.regular;
|
|
158
|
+
const growStyle = contentHeight > 0 ? { height: Math.min(contentHeight, maxHeight) } : null;
|
|
159
|
+
const scrollEnabled = contentHeight > maxHeight;
|
|
154
160
|
|
|
155
161
|
const submit = () => {
|
|
156
162
|
if (!canSubmit) return;
|
|
157
163
|
onSubmit(message);
|
|
158
164
|
setText("");
|
|
165
|
+
setContentHeight(0);
|
|
159
166
|
};
|
|
160
167
|
|
|
161
168
|
return (
|
|
@@ -184,8 +191,8 @@ export function PromptInput({
|
|
|
184
191
|
accessibilityLabel={accessibilityLabel}
|
|
185
192
|
editable={!disabled}
|
|
186
193
|
textAlignVertical="center"
|
|
187
|
-
|
|
188
|
-
|
|
194
|
+
scrollEnabled={scrollEnabled}
|
|
195
|
+
onContentSizeChange={(e) => setContentHeight(e.nativeEvent.contentSize.height)}
|
|
189
196
|
className={cn(
|
|
190
197
|
"min-h-8 flex-1 border-0 px-2 py-1 text-base font-instrument-sans",
|
|
191
198
|
largerText && "min-h-11 py-2 text-xl font-instrument-sans-medium",
|
|
@@ -195,7 +202,8 @@ export function PromptInput({
|
|
|
195
202
|
largerText ? typeStyle("medium") : null,
|
|
196
203
|
{
|
|
197
204
|
backgroundColor: "transparent",
|
|
198
|
-
maxHeight
|
|
205
|
+
maxHeight,
|
|
206
|
+
...growStyle,
|
|
199
207
|
},
|
|
200
208
|
]}
|
|
201
209
|
/>
|
|
@@ -217,6 +225,8 @@ export function PromptInput({
|
|
|
217
225
|
accessibilityLabel={accessibilityLabel}
|
|
218
226
|
editable={!disabled}
|
|
219
227
|
textAlignVertical="top"
|
|
228
|
+
scrollEnabled={scrollEnabled}
|
|
229
|
+
onContentSizeChange={(e) => setContentHeight(e.nativeEvent.contentSize.height)}
|
|
220
230
|
className={cn(
|
|
221
231
|
"min-h-11 border-0 px-3.5 py-2.5 text-base font-instrument-sans",
|
|
222
232
|
largerText && "min-h-14 px-4 py-3 text-xl font-instrument-sans-medium",
|
|
@@ -226,7 +236,8 @@ export function PromptInput({
|
|
|
226
236
|
largerText ? typeStyle("medium") : null,
|
|
227
237
|
{
|
|
228
238
|
backgroundColor: "transparent",
|
|
229
|
-
maxHeight
|
|
239
|
+
maxHeight,
|
|
240
|
+
...growStyle,
|
|
230
241
|
},
|
|
231
242
|
]}
|
|
232
243
|
/>
|
|
@@ -3,7 +3,7 @@ import { Platform } from "react-native";
|
|
|
3
3
|
import { useSSO } from "@clerk/clerk-expo";
|
|
4
4
|
import * as AuthSession from "expo-auth-session";
|
|
5
5
|
import * as WebBrowser from "expo-web-browser";
|
|
6
|
-
import { useRouter } from "expo-router";
|
|
6
|
+
import { useRouter, type Href, type Router } from "expo-router";
|
|
7
7
|
import { GoogleMark } from "../brand/GoogleMark";
|
|
8
8
|
import { Button, type ButtonProps } from "../Button";
|
|
9
9
|
import { toast } from "../toastStore";
|
|
@@ -16,9 +16,9 @@ export const GOOGLE_ERROR_MESSAGE = "Could not continue with Google";
|
|
|
16
16
|
|
|
17
17
|
export type AuthNavigationOptions = {
|
|
18
18
|
/** Where to go after a session starts. Default `"/"`. */
|
|
19
|
-
afterAuthHref?:
|
|
19
|
+
afterAuthHref?: Href;
|
|
20
20
|
/** Passed to `router.replace`, such as `{ withAnchor: true }`. */
|
|
21
|
-
replaceOptions?:
|
|
21
|
+
replaceOptions?: Parameters<Router["replace"]>[1];
|
|
22
22
|
/** Called after a session starts, in place of `router.replace`. */
|
|
23
23
|
onSuccess?: () => void;
|
|
24
24
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState, type ReactNode } from "react";
|
|
2
2
|
import { Text, View } from "react-native";
|
|
3
3
|
import { useSignIn, useSignUp } from "@clerk/clerk-expo";
|
|
4
|
-
import { useRouter } from "expo-router";
|
|
4
|
+
import { useRouter, type Href } from "expo-router";
|
|
5
5
|
import { Button } from "../Button";
|
|
6
6
|
import { textStyle } from "../controlStyles";
|
|
7
7
|
import { Input } from "../Input";
|
|
@@ -119,9 +119,9 @@ export type SignInScreenProps = AuthNavigationOptions & {
|
|
|
119
119
|
/** Default "Continue with Google". */
|
|
120
120
|
googleLabel?: string;
|
|
121
121
|
/** In `sign-in`: the "Sign up" link in the footer. No link without it. */
|
|
122
|
-
signUpHref?:
|
|
122
|
+
signUpHref?: Href;
|
|
123
123
|
/** In `sign-up`: the "Sign in" link in the footer. No link without it. */
|
|
124
|
-
signInHref?:
|
|
124
|
+
signInHref?: Href;
|
|
125
125
|
/** Sign-up rejects a shorter password before sending it. Default: no check. */
|
|
126
126
|
minPasswordLength?: number;
|
|
127
127
|
/** Default `"toast"`. `"inline"` shows errors under the fields. */
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { StyleSheet, type StyleProp, type ViewStyle } from "react-native";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* True when `style` gives a view a definite height or a flex grow. A style with
|
|
5
|
+
* only `maxHeight`, `maxWidth` or `minHeight` does not: a `flex: 1` child of
|
|
6
|
+
* such a parent collapses to height 0 on Android.
|
|
7
|
+
*/
|
|
8
|
+
export function hasDefiniteHeight(style: StyleProp<ViewStyle>): boolean {
|
|
9
|
+
const flat = StyleSheet.flatten(style);
|
|
10
|
+
if (flat == null) return false;
|
|
11
|
+
return flat.height != null || flat.flex != null || flat.flexGrow != null;
|
|
12
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// `@vvedantb/native-ui/expo-router`: components that need `expo-router`, an
|
|
2
|
+
// optional peer. Not on the root, `/layout` or `/page-title` entries: they
|
|
3
|
+
// never import it.
|
|
4
|
+
export { RouterStackPageHeader, goBackOrReplace } from "../router/RouterStackPageHeader";
|
|
5
|
+
export type { RouterStackPageHeaderProps } from "../router/RouterStackPageHeader";
|
package/src/entries/layout.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// labels and settings chrome.
|
|
3
3
|
export { SafeAreaContainer } from "../SafeAreaContainer";
|
|
4
4
|
export { ScreenContainer } from "../ScreenContainer";
|
|
5
|
+
export type { ScreenContainerProps } from "../ScreenContainer";
|
|
5
6
|
export { PageTitle, PageHeader } from "../PageTitle";
|
|
6
7
|
export type { PageTitleProps, PageHeaderProps } from "../PageTitle";
|
|
7
8
|
export { StackPageHeader } from "../StackPageHeader";
|
|
@@ -12,6 +13,12 @@ export { SettingsSection } from "../settings/SettingsSection";
|
|
|
12
13
|
export { SettingsField } from "../settings/SettingsField";
|
|
13
14
|
export { SettingsToggleRow } from "../settings/SettingsToggleRow";
|
|
14
15
|
export { SettingsStack } from "../settings/SettingsStack";
|
|
16
|
+
export { SettingsEmptyState } from "../settings/SettingsEmptyState";
|
|
17
|
+
export type { SettingsEmptyStateProps } from "../settings/SettingsEmptyState";
|
|
18
|
+
export { PinnedBotTile, BotPinGrid } from "../PinnedBotTile";
|
|
19
|
+
export type { PinnedBotTileProps, BotPinGridProps } from "../PinnedBotTile";
|
|
20
|
+
export { ArtifactList } from "../ArtifactList";
|
|
21
|
+
export type { ArtifactListProps, ArtifactRow } from "../ArtifactList";
|
|
15
22
|
export { MoreFromProjectV, FromProjectV } from "../settings/MoreFromProjectV";
|
|
16
23
|
export type { MoreFromProjectVProps, FromProjectVProps } from "../settings/MoreFromProjectV";
|
|
17
24
|
export {
|
package/src/index.ts
CHANGED
|
@@ -182,6 +182,7 @@ export type { DateTimeFieldProps } from "./DateTimeField";
|
|
|
182
182
|
export { BottomSheetContainer } from "./BottomSheetContainer";
|
|
183
183
|
export { SafeAreaContainer } from "./SafeAreaContainer";
|
|
184
184
|
export { ScreenContainer } from "./ScreenContainer";
|
|
185
|
+
export type { ScreenContainerProps } from "./ScreenContainer";
|
|
185
186
|
export { PageTitle, PageHeader } from "./PageTitle";
|
|
186
187
|
export type { PageTitleProps, PageHeaderProps } from "./PageTitle";
|
|
187
188
|
export { StackPageHeader } from "./StackPageHeader";
|
|
@@ -190,6 +191,12 @@ export { SettingsSection } from "./settings/SettingsSection";
|
|
|
190
191
|
export { SettingsField } from "./settings/SettingsField";
|
|
191
192
|
export { SettingsToggleRow } from "./settings/SettingsToggleRow";
|
|
192
193
|
export { SettingsStack } from "./settings/SettingsStack";
|
|
194
|
+
export { SettingsEmptyState } from "./settings/SettingsEmptyState";
|
|
195
|
+
export type { SettingsEmptyStateProps } from "./settings/SettingsEmptyState";
|
|
196
|
+
export { PinnedBotTile, BotPinGrid } from "./PinnedBotTile";
|
|
197
|
+
export type { PinnedBotTileProps, BotPinGridProps } from "./PinnedBotTile";
|
|
198
|
+
export { ArtifactList } from "./ArtifactList";
|
|
199
|
+
export type { ArtifactListProps, ArtifactRow } from "./ArtifactList";
|
|
193
200
|
export { MoreFromProjectV, FromProjectV } from "./settings/MoreFromProjectV";
|
|
194
201
|
export type { MoreFromProjectVProps, FromProjectVProps } from "./settings/MoreFromProjectV";
|
|
195
202
|
export {
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { useRouter, type Href, type Router } from "expo-router";
|
|
2
|
+
import { StackPageHeader, type StackPageHeaderProps } from "../StackPageHeader";
|
|
3
|
+
|
|
4
|
+
export type RouterStackPageHeaderProps = Omit<StackPageHeaderProps, "onBack"> & {
|
|
5
|
+
/** Where the back button goes when there is nothing to go back to, such as after a deep link. Default `"/"`. */
|
|
6
|
+
fallbackHref?: Href;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
/** Goes back when the stack has a screen below, else replaces the route with `fallbackHref`. */
|
|
10
|
+
export function goBackOrReplace(router: Pick<Router, "canGoBack" | "back" | "replace">, fallbackHref: Href = "/") {
|
|
11
|
+
if (router.canGoBack()) router.back();
|
|
12
|
+
else router.replace(fallbackHref);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `StackPageHeader` with a router-aware back button: `router.canGoBack() ?
|
|
17
|
+
* router.back() : router.replace(fallbackHref)`. Same props as
|
|
18
|
+
* `StackPageHeader` without `onBack`, and `fallbackHref` (default `"/"`).
|
|
19
|
+
* On `@vvedantb/native-ui/expo-router` only, so the other entries never import
|
|
20
|
+
* `expo-router`.
|
|
21
|
+
*/
|
|
22
|
+
export function RouterStackPageHeader({ fallbackHref = "/", ...props }: RouterStackPageHeaderProps) {
|
|
23
|
+
const router = useRouter();
|
|
24
|
+
return <StackPageHeader {...props} onBack={() => goBackOrReplace(router, fallbackHref)} />;
|
|
25
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
import { Text, View } from "react-native";
|
|
3
|
+
import type { IconProps } from "@tabler/icons-react-native";
|
|
4
|
+
import { textStyle, typeStyle } from "../controlStyles";
|
|
5
|
+
import { useLargerText } from "../theme";
|
|
6
|
+
import { tokens } from "../tokens";
|
|
7
|
+
import { useScheme } from "../useScheme";
|
|
8
|
+
|
|
9
|
+
export type SettingsEmptyStateProps = {
|
|
10
|
+
title: string;
|
|
11
|
+
description?: string;
|
|
12
|
+
/** Optional icon above the title, as on the web. */
|
|
13
|
+
icon?: ComponentType<IconProps>;
|
|
14
|
+
/** Below the text, such as a `Button`. */
|
|
15
|
+
action?: ReactNode;
|
|
16
|
+
testID?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A compact empty note for the inside of a `SettingsSection` (web
|
|
21
|
+
* `SettingsEmptyState`): centred title, muted description and an optional
|
|
22
|
+
* action, with no large icon. Use `EmptyState` for a whole screen.
|
|
23
|
+
*/
|
|
24
|
+
export function SettingsEmptyState({ title, description, icon: Icon, action, testID }: SettingsEmptyStateProps) {
|
|
25
|
+
const scheme = useScheme();
|
|
26
|
+
const largerText = useLargerText();
|
|
27
|
+
return (
|
|
28
|
+
<View testID={testID} className="items-center gap-1 px-2 py-4">
|
|
29
|
+
{Icon ? <Icon size={largerText ? 24 : 20} color={tokens.color.muted[scheme]} /> : null}
|
|
30
|
+
<Text
|
|
31
|
+
className="text-center font-instrument-sans-semibold text-base"
|
|
32
|
+
style={[textStyle("foreground", scheme), typeStyle("semibold")]}
|
|
33
|
+
>
|
|
34
|
+
{title}
|
|
35
|
+
</Text>
|
|
36
|
+
{description ? (
|
|
37
|
+
<Text className="text-center font-instrument-sans text-sm" style={textStyle("muted", scheme)}>
|
|
38
|
+
{description}
|
|
39
|
+
</Text>
|
|
40
|
+
) : null}
|
|
41
|
+
{action ? <View className="mt-2">{action}</View> : null}
|
|
42
|
+
</View>
|
|
43
|
+
);
|
|
44
|
+
}
|