@vvedantb/native-ui 0.3.17 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +75 -1
- package/package.json +10 -2
- package/src/ai/activity-step.tsx +57 -0
- package/src/ai/attachments.tsx +153 -0
- package/src/ai/chain-of-thought.tsx +143 -0
- package/src/ai/chat-text.ts +35 -0
- package/src/ai/code-block.tsx +104 -0
- package/src/ai/collapsible.tsx +111 -0
- package/src/ai/conversation.tsx +245 -0
- package/src/ai/file-chip.tsx +66 -0
- package/src/ai/icon-button.tsx +51 -0
- package/src/ai/index.ts +123 -0
- package/src/ai/message.tsx +146 -0
- package/src/ai/prompt-input.tsx +186 -0
- package/src/ai/reasoning.tsx +200 -0
- package/src/ai/response-text.tsx +56 -0
- package/src/ai/shimmer.tsx +86 -0
- package/src/ai/sources.tsx +100 -0
- package/src/ai/suggestion.tsx +93 -0
- package/src/ai/trace.tsx +234 -0
- package/src/ai/use-streaming-disclosure.ts +50 -0
- package/src/entries/ai.ts +4 -0
- package/src/examples/AiChatGallery.tsx +233 -0
- package/src/examples/ParityGallery.tsx +35 -0
package/README.md
CHANGED
|
@@ -73,8 +73,12 @@ The root entry exports everything. It still imports `BottomSheetContainer` and `
|
|
|
73
73
|
| `@vvedantb/native-ui/blur`, `/haptics` | the loaders | `expo-blur`, `expo-haptics` (loaders) |
|
|
74
74
|
| `@vvedantb/native-ui/bottom-sheet` | `BottomSheetContainer` | `@gorhom/bottom-sheet` (import), `expo-blur` (loader) |
|
|
75
75
|
| `@vvedantb/native-ui/date-time` | `DateTimeField` | `@react-native-community/datetimepicker`, `dayjs` (import) |
|
|
76
|
+
| `@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 |
|
|
77
|
+
| `@vvedantb/native-ui/ai-gallery` | `AiChatGallery`, `AiChatSamples` (from 0.4.0) | none |
|
|
76
78
|
|
|
77
|
-
"Loader" means the `require` inside `try` described above, so the entry bundles and runs without that peer. `@vvedantb/native-ui/gallery` imports the root entry.
|
|
79
|
+
"Loader" means the `require` inside `try` described above, so the entry bundles and runs without that peer. `@vvedantb/native-ui/gallery` imports the root entry; `@vvedantb/native-ui/ai-gallery` imports `/ai` only.
|
|
80
|
+
|
|
81
|
+
The AI chat parts are only on `/ai`. Metro does not tree-shake by default, so on the root entry they would bundle into every app, with or without a chat.
|
|
78
82
|
|
|
79
83
|
```tsx
|
|
80
84
|
import { FloatingTabBar } from "@vvedantb/native-ui/tab-bar";
|
|
@@ -193,6 +197,76 @@ From 0.3.12 a toast looks like the web `SonnerToaster` at its default density wi
|
|
|
193
197
|
|
|
194
198
|
`@vvedantb/native-ui/gallery` exports `ParityGallery`, a screen that renders these components. Put it in a dev-only route to check them on a device.
|
|
195
199
|
|
|
200
|
+
## AI chat (0.4.0)
|
|
201
|
+
|
|
202
|
+
`@vvedantb/native-ui/ai` ports the `@vvedantb/ui` AI elements for touch: the thread, messages, reasoning and agent trace folds, the composer, suggestions, sources, attachments and code. They are generic (no model names, no app logic) and say "the AI", "Thinking…", "Thought for 12s" and "Worked for 12s". They add no package.
|
|
203
|
+
|
|
204
|
+
```tsx
|
|
205
|
+
import {
|
|
206
|
+
Conversation,
|
|
207
|
+
ConversationContent,
|
|
208
|
+
ConversationScrollButton,
|
|
209
|
+
Message,
|
|
210
|
+
MessageAction,
|
|
211
|
+
MessageActionsBar,
|
|
212
|
+
MessageContent,
|
|
213
|
+
PromptInput,
|
|
214
|
+
Reasoning,
|
|
215
|
+
ReasoningContent,
|
|
216
|
+
ReasoningTrigger,
|
|
217
|
+
ResponseText,
|
|
218
|
+
} from "@vvedantb/native-ui/ai";
|
|
219
|
+
|
|
220
|
+
<View className="flex-1">
|
|
221
|
+
<Conversation>
|
|
222
|
+
<ConversationContent>
|
|
223
|
+
<Message from="user">
|
|
224
|
+
<MessageContent>{question}</MessageContent>
|
|
225
|
+
</Message>
|
|
226
|
+
<Message from="assistant">
|
|
227
|
+
<MessageContent>
|
|
228
|
+
<Reasoning isStreaming={status === "streaming"}>
|
|
229
|
+
<ReasoningTrigger />
|
|
230
|
+
<ReasoningContent>{reasoning}</ReasoningContent>
|
|
231
|
+
</Reasoning>
|
|
232
|
+
<ResponseText>{answer}</ResponseText>
|
|
233
|
+
</MessageContent>
|
|
234
|
+
<MessageActionsBar>
|
|
235
|
+
<MessageAction kind="copy" onPress={() => copy(answer)} />
|
|
236
|
+
<MessageAction kind="retry" onPress={retry} />
|
|
237
|
+
</MessageActionsBar>
|
|
238
|
+
</Message>
|
|
239
|
+
</ConversationContent>
|
|
240
|
+
<ConversationScrollButton />
|
|
241
|
+
</Conversation>
|
|
242
|
+
<PromptInput onSubmit={send} status={status} onStop={stop} />
|
|
243
|
+
</View>
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
| Part | Notes |
|
|
247
|
+
| --- | --- |
|
|
248
|
+
| `Conversation`, `ConversationContent`, `ConversationEmptyState`, `ConversationScrollButton`, `useConversation` | A `ScrollView` that sticks to the bottom: while the reader is within 70 of the end, new and growing messages stay in view. Once they drag up it stops following, and the scroll button shows: a round outline arrow (40, slop to 48), 16 above the bottom, with an opaque fill and no shadow. Pressing it scrolls down (a jump with Reduce Motion). For very long threads, drive a `FlatList` with `useConversation`. |
|
|
249
|
+
| `Message`, `MessageContent`, `MessageActionsBar`, `MessageAction` | A user message is the web primary bubble (radius 16, bottom-right 14, `px-4 py-3`) at the end of the row; an assistant message has no fill. `MessageAction` is a ghost icon button at the web `icon-sm` size (32, slop to 44). `kind` gives the icon and label: "Copy", "Retry", "Good response", "Bad response". `selected` marks chosen feedback. |
|
|
250
|
+
| `Reasoning`, `ReasoningTrigger`, `ReasoningContent`, `useReasoning` | Opens when streaming starts, times the stream and closes once, a second after it stops. The trigger shows a brain, the shimmering "Thinking…", then "Thought for 12s", and a chevron. The content is muted monospace text on the web `bg-surface-secondary/40` panel. Unlike the web, a reader can close it while it streams. |
|
|
251
|
+
| `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. |
|
|
252
|
+
| `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)}` ``. |
|
|
253
|
+
| `ChainOfThought` and its parts | Simplified: header, steps with a rail, search-result chips. |
|
|
254
|
+
| `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}`. |
|
|
255
|
+
| `Suggestions`, `Suggestion` | A horizontal row of round chips (or `orientation="vertical"`). `onPress` gets the suggestion text. |
|
|
256
|
+
| `Sources`, `SourcesTrigger`, `SourcesContent`, `Source` | A fold of link rows. A row opens its address with `Linking.openURL`, or hands it to `onPress`. |
|
|
257
|
+
| `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. |
|
|
258
|
+
| `CodeBlock`, `CodeBlockCopyButton` | Plain monospace code on the flat panel, scrolling sideways, with the language and a copy button in a header. No highlighting. |
|
|
259
|
+
| `ResponseText` | Plain, selectable reply text with newlines kept. It takes the colour of the `MessageContent` it is in. |
|
|
260
|
+
| `Shimmer` | "Live" text: the foreground fades to 0.45 and back on the UI thread. With Reduce Motion it holds still, muted. |
|
|
261
|
+
|
|
262
|
+
**Markdown.** There is no markdown renderer: `ResponseText` draws the text as it is. To render markdown, an app can add one of these and use it in place of `ResponseText`: `react-native-markdown-display`, `@ronradtke/react-native-markdown-display` (a maintained fork) or `react-native-marked`.
|
|
263
|
+
|
|
264
|
+
**Clipboard.** React Native's own `Clipboard` is deprecated (moved out of core), and the package adds no clipboard package. `MessageAction kind="copy"` calls your `onPress`, and `CodeBlock` shows its copy button when given `onCopy(code)`. Pass your own clipboard call, for example `expo-clipboard`'s `setStringAsync`.
|
|
265
|
+
|
|
266
|
+
Every control has a role, an accessible name and a 44 pt target. Folds report `expanded`, feedback actions `selected`, and the send button `disabled`. Type and icons step up with larger text. Colours come from `palette` and `tokens` as styles, in light and dark.
|
|
267
|
+
|
|
268
|
+
`@vvedantb/native-ui/ai-gallery` exports `AiChatGallery`, a full chat screen with a sample thread: a user message, a finished assistant message with folded reasoning and trace, code, sources and actions, and a second answer that streams with its reasoning open until you press Stop. It also has suggestions and the composer with sample attachments. Put it in a dev-only route with a `SafeAreaProvider` above it. `ParityGallery` shows the same thread (`AiChatSamples`) in its "AI chat" section.
|
|
269
|
+
|
|
196
270
|
## Phones and accessibility
|
|
197
271
|
|
|
198
272
|
The components keep the web sizes on screen and reach a 44 pt touch target with `hitSlop` (`touchSlop` in `src/controlStyles.ts`): `Checkbox` (16), `Switch` (36 × 20), tab triggers (30 high), the `Select` trigger (40 high), the `SearchInput` clear button (24), the `UpgradeToProCard` pill (32 high, slop 8) and `Button` (the slop brings each size to at least 48). Controls set their role and state for VoiceOver and TalkBack: `Button` (`disabled`, `busy` while loading), `Checkbox` (`checked`, `mixed`), `Switch` (`checked`), `Tabs` and `PillTabs` (`tablist`, `tab`, `selected`) and the `Select` trigger (`combobox`, with an `accessibilityLabel` prop). Give a `Button` with no text child an `accessibilityLabel`.
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/native-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"license": "MIT",
|
|
5
|
-
"description": "Shared React Native / Expo UI primitives. Floating surfaces, forms, overlays, toasts, settings chrome, tab bar and the family app marks.",
|
|
5
|
+
"description": "Shared React Native / Expo UI primitives. Floating surfaces, forms, overlays, toasts, settings chrome, tab bar, AI chat parts and the family app marks.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"publishConfig": {
|
|
8
8
|
"access": "public"
|
|
@@ -73,10 +73,18 @@
|
|
|
73
73
|
"types": "./src/entries/date-time.ts",
|
|
74
74
|
"default": "./src/entries/date-time.ts"
|
|
75
75
|
},
|
|
76
|
+
"./ai": {
|
|
77
|
+
"types": "./src/entries/ai.ts",
|
|
78
|
+
"default": "./src/entries/ai.ts"
|
|
79
|
+
},
|
|
76
80
|
"./gallery": {
|
|
77
81
|
"types": "./src/examples/ParityGallery.tsx",
|
|
78
82
|
"default": "./src/examples/ParityGallery.tsx"
|
|
79
83
|
},
|
|
84
|
+
"./ai-gallery": {
|
|
85
|
+
"types": "./src/examples/AiChatGallery.tsx",
|
|
86
|
+
"default": "./src/examples/AiChatGallery.tsx"
|
|
87
|
+
},
|
|
80
88
|
"./blur": {
|
|
81
89
|
"types": "./src/blur.ts",
|
|
82
90
|
"default": "./src/blur.ts"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
import { IconAlertTriangle, IconCheck, type IconProps } from "@tabler/icons-react-native";
|
|
3
|
+
import { Spinner } from "../Spinner";
|
|
4
|
+
import { useLargerText } from "../theme";
|
|
5
|
+
import { AgentTraceDescription, AgentTraceIcon, AgentTraceLine, AgentTraceTitle } from "./trace";
|
|
6
|
+
|
|
7
|
+
export type ActivityStepStatus = "running" | "complete" | "error";
|
|
8
|
+
|
|
9
|
+
export type ActivityStepProps = {
|
|
10
|
+
/** What the AI is doing, such as "Searching the web" or "Reading 3 files". */
|
|
11
|
+
label: string;
|
|
12
|
+
/** The tool's icon. Without one, the line shows the status icon only. */
|
|
13
|
+
icon?: ComponentType<IconProps>;
|
|
14
|
+
/** `running` pulses the label and shows a spinner; `complete` a check; `error` a warning in the danger colour. Default `complete`. */
|
|
15
|
+
status?: ActivityStepStatus;
|
|
16
|
+
/** Faded detail after the label, such as "Worked for 12s" (see `formatElapsed`). */
|
|
17
|
+
description?: string;
|
|
18
|
+
testID?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* One tool or activity step as a single trace line: icon, label, status and
|
|
23
|
+
* an optional description. A light stand-in for the web `ActivityTasks`, built
|
|
24
|
+
* on the `AgentTrace` parts. Its accessibility label reads the label, the
|
|
25
|
+
* status and the description.
|
|
26
|
+
*/
|
|
27
|
+
export function ActivityStep({
|
|
28
|
+
label,
|
|
29
|
+
icon,
|
|
30
|
+
status = "complete",
|
|
31
|
+
description,
|
|
32
|
+
testID,
|
|
33
|
+
}: ActivityStepProps) {
|
|
34
|
+
const largerText = useLargerText();
|
|
35
|
+
const running = status === "running";
|
|
36
|
+
const state = running ? "in progress" : status === "error" ? "failed" : "done";
|
|
37
|
+
return (
|
|
38
|
+
<AgentTraceLine
|
|
39
|
+
testID={testID}
|
|
40
|
+
status={status === "error" ? "error" : undefined}
|
|
41
|
+
accessible
|
|
42
|
+
accessibilityLabel={[label, state, description].filter(Boolean).join(", ")}
|
|
43
|
+
accessibilityState={{ busy: running }}
|
|
44
|
+
>
|
|
45
|
+
{icon ? <AgentTraceIcon icon={icon} /> : null}
|
|
46
|
+
<AgentTraceTitle shimmer={running}>{label}</AgentTraceTitle>
|
|
47
|
+
{description ? <AgentTraceDescription>{description}</AgentTraceDescription> : null}
|
|
48
|
+
{running ? (
|
|
49
|
+
<AgentTraceIcon>
|
|
50
|
+
<Spinner size={largerText ? "md" : "sm"} decorative />
|
|
51
|
+
</AgentTraceIcon>
|
|
52
|
+
) : (
|
|
53
|
+
<AgentTraceIcon icon={status === "error" ? IconAlertTriangle : IconCheck} />
|
|
54
|
+
)}
|
|
55
|
+
</AgentTraceLine>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createContext, useContext, type ComponentType } from "react";
|
|
2
|
+
import { Image, Pressable, View, type ViewProps } from "react-native";
|
|
3
|
+
import {
|
|
4
|
+
IconFileText,
|
|
5
|
+
IconMusic,
|
|
6
|
+
IconPaperclip,
|
|
7
|
+
IconPhoto,
|
|
8
|
+
IconVideo,
|
|
9
|
+
IconWorld,
|
|
10
|
+
IconX,
|
|
11
|
+
type IconProps,
|
|
12
|
+
} from "@tabler/icons-react-native";
|
|
13
|
+
import { cn } from "../cn";
|
|
14
|
+
import { touchSlop } from "../controlStyles";
|
|
15
|
+
import { useLargerText } from "../theme";
|
|
16
|
+
import { tokens } from "../tokens";
|
|
17
|
+
import { useScheme } from "../useScheme";
|
|
18
|
+
import { ResponseText } from "./response-text";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A file or source attached to a prompt or message. The fields the web reads
|
|
22
|
+
* from the `ai` SDK `FileUIPart` and `SourceDocumentUIPart`, without the SDK.
|
|
23
|
+
*/
|
|
24
|
+
export type AttachmentData = {
|
|
25
|
+
id: string;
|
|
26
|
+
/** `"source-document"` for a referenced source. Default a file. */
|
|
27
|
+
type?: "file" | "source-document";
|
|
28
|
+
filename?: string;
|
|
29
|
+
/** MIME type, such as `image/png`. */
|
|
30
|
+
mediaType?: string;
|
|
31
|
+
/** A local or remote URI. An image with one shows a thumbnail. */
|
|
32
|
+
url?: string;
|
|
33
|
+
/** A source document's title. */
|
|
34
|
+
title?: string;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type AttachmentMediaCategory = "image" | "video" | "audio" | "document" | "source" | "unknown";
|
|
38
|
+
|
|
39
|
+
const MEDIA_ICONS: Record<AttachmentMediaCategory, ComponentType<IconProps>> = {
|
|
40
|
+
audio: IconMusic,
|
|
41
|
+
document: IconFileText,
|
|
42
|
+
image: IconPhoto,
|
|
43
|
+
source: IconWorld,
|
|
44
|
+
unknown: IconPaperclip,
|
|
45
|
+
video: IconVideo,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The kind of attachment, from its type and MIME type, as on the web. */
|
|
49
|
+
export function getMediaCategory(data: AttachmentData): AttachmentMediaCategory {
|
|
50
|
+
if (data.type === "source-document") return "source";
|
|
51
|
+
const mediaType = data.mediaType ?? "";
|
|
52
|
+
if (mediaType.startsWith("image/")) return "image";
|
|
53
|
+
if (mediaType.startsWith("video/")) return "video";
|
|
54
|
+
if (mediaType.startsWith("audio/")) return "audio";
|
|
55
|
+
if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) return "document";
|
|
56
|
+
return "unknown";
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The name to show and read, as on the web: the title or file name, else "Source", "Image" or "Attachment". */
|
|
60
|
+
export function getAttachmentLabel(data: AttachmentData): string {
|
|
61
|
+
if (data.type === "source-document") return data.title || data.filename || "Source";
|
|
62
|
+
return data.filename || (getMediaCategory(data) === "image" ? "Image" : "Attachment");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** `inline` (default) wraps chips in rows; `list` stacks them full width. The web `grid` previews are not ported. */
|
|
66
|
+
export type AttachmentVariant = "inline" | "list";
|
|
67
|
+
|
|
68
|
+
const AttachmentsContext = createContext<AttachmentVariant>("inline");
|
|
69
|
+
|
|
70
|
+
export type AttachmentsProps = ViewProps & {
|
|
71
|
+
variant?: AttachmentVariant;
|
|
72
|
+
className?: string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
/** The attachments of a prompt or message. */
|
|
76
|
+
export function Attachments({ variant = "inline", className, ...props }: AttachmentsProps) {
|
|
77
|
+
return (
|
|
78
|
+
<AttachmentsContext.Provider value={variant}>
|
|
79
|
+
<View
|
|
80
|
+
className={cn(variant === "list" ? "gap-2" : "flex-row flex-wrap items-start gap-2", className)}
|
|
81
|
+
{...props}
|
|
82
|
+
/>
|
|
83
|
+
</AttachmentsContext.Provider>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export type AttachmentProps = {
|
|
88
|
+
data: AttachmentData;
|
|
89
|
+
/** Shows a remove button, labelled "Remove" and the attachment name. */
|
|
90
|
+
onRemove?: () => void;
|
|
91
|
+
testID?: string;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
/** A remove cross of 20 in a 32 row: the slop brings it to 44. */
|
|
95
|
+
const REMOVE_SIZE = 20;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* One attachment as a chip: a 20 thumbnail for an image with a `url`, else
|
|
99
|
+
* its media icon, then the name and an optional remove button. Flat, on the
|
|
100
|
+
* web `flatPanelSubtle` fill (`bg-surface-secondary`). No hover card.
|
|
101
|
+
*/
|
|
102
|
+
export function Attachment({ data, onRemove, testID }: AttachmentProps) {
|
|
103
|
+
const variant = useContext(AttachmentsContext);
|
|
104
|
+
const scheme = useScheme();
|
|
105
|
+
const largerText = useLargerText();
|
|
106
|
+
const category = getMediaCategory(data);
|
|
107
|
+
const label = getAttachmentLabel(data);
|
|
108
|
+
const Icon = MEDIA_ICONS[category];
|
|
109
|
+
const iconSize = largerText ? 20 : 16;
|
|
110
|
+
const muted = tokens.color.muted[scheme];
|
|
111
|
+
return (
|
|
112
|
+
<View
|
|
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
|
+
>
|
|
121
|
+
{category === "image" && data.url ? (
|
|
122
|
+
<Image
|
|
123
|
+
source={{ uri: data.url }}
|
|
124
|
+
resizeMode="cover"
|
|
125
|
+
style={{ width: 20, height: 20, borderRadius: 4 }}
|
|
126
|
+
/>
|
|
127
|
+
) : (
|
|
128
|
+
<Icon size={iconSize} color={muted} />
|
|
129
|
+
)}
|
|
130
|
+
<ResponseText
|
|
131
|
+
size="small"
|
|
132
|
+
selectable={false}
|
|
133
|
+
numberOfLines={1}
|
|
134
|
+
ellipsizeMode="middle"
|
|
135
|
+
className={variant === "list" ? "flex-1" : "shrink"}
|
|
136
|
+
>
|
|
137
|
+
{label}
|
|
138
|
+
</ResponseText>
|
|
139
|
+
{onRemove ? (
|
|
140
|
+
<Pressable
|
|
141
|
+
accessibilityRole="button"
|
|
142
|
+
accessibilityLabel={`Remove ${label}`}
|
|
143
|
+
hitSlop={touchSlop(REMOVE_SIZE)}
|
|
144
|
+
onPress={onRemove}
|
|
145
|
+
className="items-center justify-center rounded-full active:opacity-70"
|
|
146
|
+
style={{ width: REMOVE_SIZE, height: REMOVE_SIZE }}
|
|
147
|
+
>
|
|
148
|
+
<IconX size={largerText ? 16 : 14} color={muted} />
|
|
149
|
+
</Pressable>
|
|
150
|
+
) : null}
|
|
151
|
+
</View>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
import { Text, View, type ViewProps } from "react-native";
|
|
3
|
+
import { IconBrain, IconPointFilled, type IconProps } from "@tabler/icons-react-native";
|
|
4
|
+
import { badgeColors, Badge } from "../Badge";
|
|
5
|
+
import { cn } from "../cn";
|
|
6
|
+
import { sizeStyle } from "../controlStyles";
|
|
7
|
+
import { useLargerText } from "../theme";
|
|
8
|
+
import { tokens } from "../tokens";
|
|
9
|
+
import { useScheme } from "../useScheme";
|
|
10
|
+
import { chatTextSize } from "./chat-text";
|
|
11
|
+
import {
|
|
12
|
+
Collapsible,
|
|
13
|
+
CollapsibleChevron,
|
|
14
|
+
CollapsibleContent,
|
|
15
|
+
CollapsibleTrigger,
|
|
16
|
+
useCollapsible,
|
|
17
|
+
type CollapsibleProps,
|
|
18
|
+
type CollapsibleTriggerProps,
|
|
19
|
+
} from "./collapsible";
|
|
20
|
+
import { ResponseText } from "./response-text";
|
|
21
|
+
|
|
22
|
+
// The web `ChainOfThought`, simplified: a fold of steps with search-result
|
|
23
|
+
// chips. `ChainOfThoughtImage` and the step enter animation are not ported.
|
|
24
|
+
|
|
25
|
+
/** Holds its open state (default closed), or takes `open` and `onOpenChange`. */
|
|
26
|
+
export function ChainOfThought({ className, ...props }: CollapsibleProps) {
|
|
27
|
+
return <Collapsible className={cn("w-full gap-4", className)} {...props} />;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type ChainOfThoughtHeaderProps = Omit<CollapsibleTriggerProps, "children"> & {
|
|
31
|
+
/** Default "Chain of Thought". A string, as it is also the accessibility label. */
|
|
32
|
+
children?: string;
|
|
33
|
+
icon?: ComponentType<IconProps>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Brain icon, title and chevron: toggles the steps. */
|
|
37
|
+
export function ChainOfThoughtHeader({
|
|
38
|
+
children = "Chain of Thought",
|
|
39
|
+
icon: Icon = IconBrain,
|
|
40
|
+
className,
|
|
41
|
+
...props
|
|
42
|
+
}: ChainOfThoughtHeaderProps) {
|
|
43
|
+
const { open } = useCollapsible();
|
|
44
|
+
const scheme = useScheme();
|
|
45
|
+
const largerText = useLargerText();
|
|
46
|
+
const muted = tokens.color.muted[scheme];
|
|
47
|
+
const iconSize = largerText ? 20 : 16;
|
|
48
|
+
return (
|
|
49
|
+
<CollapsibleTrigger
|
|
50
|
+
accessibilityLabel={children}
|
|
51
|
+
className={cn("w-full flex-row items-center gap-2", className)}
|
|
52
|
+
{...props}
|
|
53
|
+
>
|
|
54
|
+
<Icon size={iconSize} color={muted} />
|
|
55
|
+
<ResponseText size="small" tone="muted" selectable={false} className="flex-1">
|
|
56
|
+
{children}
|
|
57
|
+
</ResponseText>
|
|
58
|
+
<CollapsibleChevron open={open} size={iconSize} color={muted} />
|
|
59
|
+
</CollapsibleTrigger>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The steps, shown while open. */
|
|
64
|
+
export function ChainOfThoughtContent({ className, ...props }: ViewProps & { className?: string }) {
|
|
65
|
+
return <CollapsibleContent className={cn("gap-3", className)} {...props} />;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type ChainOfThoughtStepStatus = "complete" | "active" | "pending";
|
|
69
|
+
|
|
70
|
+
export type ChainOfThoughtStepProps = ViewProps & {
|
|
71
|
+
icon?: ComponentType<IconProps>;
|
|
72
|
+
label: string;
|
|
73
|
+
description?: string;
|
|
74
|
+
/** `active` is the foreground, `complete` muted, `pending` muted at half opacity, as on the web. Default `complete`. */
|
|
75
|
+
status?: ChainOfThoughtStepStatus;
|
|
76
|
+
className?: string;
|
|
77
|
+
children?: ReactNode;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** One step: icon with a rail under it to the next step, label, description and any children (such as search results). */
|
|
81
|
+
export function ChainOfThoughtStep({
|
|
82
|
+
icon: Icon = IconPointFilled,
|
|
83
|
+
label,
|
|
84
|
+
description,
|
|
85
|
+
status = "complete",
|
|
86
|
+
className,
|
|
87
|
+
style,
|
|
88
|
+
children,
|
|
89
|
+
...props
|
|
90
|
+
}: ChainOfThoughtStepProps) {
|
|
91
|
+
const scheme = useScheme();
|
|
92
|
+
const largerText = useLargerText();
|
|
93
|
+
const tone = status === "active" ? "foreground" : "muted";
|
|
94
|
+
return (
|
|
95
|
+
<View
|
|
96
|
+
className={cn("flex-row gap-2", className)}
|
|
97
|
+
style={[status === "pending" ? { opacity: 0.5 } : null, style]}
|
|
98
|
+
{...props}
|
|
99
|
+
>
|
|
100
|
+
<View className="mt-0.5 items-center">
|
|
101
|
+
<Icon size={largerText ? 20 : 16} color={tokens.color[tone][scheme]} />
|
|
102
|
+
<View
|
|
103
|
+
className="mt-2 w-px flex-1"
|
|
104
|
+
style={{ backgroundColor: tokens.color.separator[scheme] }}
|
|
105
|
+
/>
|
|
106
|
+
</View>
|
|
107
|
+
<View className="min-w-0 flex-1 gap-2">
|
|
108
|
+
<ResponseText size="small" tone={tone}>
|
|
109
|
+
{label}
|
|
110
|
+
</ResponseText>
|
|
111
|
+
{description ? (
|
|
112
|
+
<ResponseText size="caption" tone="muted">
|
|
113
|
+
{description}
|
|
114
|
+
</ResponseText>
|
|
115
|
+
) : null}
|
|
116
|
+
{children}
|
|
117
|
+
</View>
|
|
118
|
+
</View>
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A wrapping row of `ChainOfThoughtSearchResult` chips. */
|
|
123
|
+
export function ChainOfThoughtSearchResults({ className, ...props }: ViewProps & { className?: string }) {
|
|
124
|
+
return <View className={cn("flex-row flex-wrap items-center gap-2", className)} {...props} />;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** One search result: a `secondary` `Badge` with regular `text-xs` type, as the web `font-normal text-xs`. */
|
|
128
|
+
export function ChainOfThoughtSearchResult({ children, className }: { children: string; className?: string }) {
|
|
129
|
+
const scheme = useScheme();
|
|
130
|
+
const largerText = useLargerText();
|
|
131
|
+
const size = chatTextSize("caption", largerText);
|
|
132
|
+
return (
|
|
133
|
+
<Badge variant="secondary" className={cn("px-2 py-0.5", className)}>
|
|
134
|
+
<Text
|
|
135
|
+
numberOfLines={1}
|
|
136
|
+
className={cn(size, "font-instrument-sans")}
|
|
137
|
+
style={[sizeStyle(size), { color: badgeColors("secondary", scheme).color }]}
|
|
138
|
+
>
|
|
139
|
+
{children}
|
|
140
|
+
</Text>
|
|
141
|
+
</Badge>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Type and copy shared by the AI chat parts. */
|
|
2
|
+
import { Platform } from "react-native";
|
|
3
|
+
import type { TextSize } from "../controlStyles";
|
|
4
|
+
|
|
5
|
+
export type ChatTextKind = "body" | "small" | "caption";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `body` is message text: `text-base` (16 / 24), the phone body size (the web
|
|
9
|
+
* `MessageContent` is `text-sm`, but the web composer is 16 on phones).
|
|
10
|
+
* `small` is the secondary text of triggers, trace lines and sources:
|
|
11
|
+
* `text-sm` (14 / 20; the web sources trigger is `text-xs`). `caption` is
|
|
12
|
+
* `text-xs` (12 / 16): step descriptions, file chips and code headers. Larger
|
|
13
|
+
* text steps each up, as `Textarea` does.
|
|
14
|
+
*/
|
|
15
|
+
export function chatTextSize(kind: ChatTextKind, largerText: boolean): TextSize {
|
|
16
|
+
if (kind === "body") return largerText ? "text-xl" : "text-base";
|
|
17
|
+
if (kind === "small") return largerText ? "text-lg" : "text-sm";
|
|
18
|
+
return largerText ? "text-base" : "text-xs";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The system monospace face, for code and reasoning text (web `font-mono`). */
|
|
22
|
+
export const MONO_FONT = Platform.select({ ios: "Menlo", default: "monospace" });
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Elapsed time for "Thought for 12s" and "Worked for 1m 5s": whole seconds
|
|
26
|
+
* under a minute, minutes and seconds above. Negative and fractional values
|
|
27
|
+
* round to whole seconds, from 0.
|
|
28
|
+
*/
|
|
29
|
+
export function formatElapsed(seconds: number): string {
|
|
30
|
+
const total = Math.max(0, Math.round(seconds));
|
|
31
|
+
if (total < 60) return `${total}s`;
|
|
32
|
+
const minutes = Math.floor(total / 60);
|
|
33
|
+
const rest = total % 60;
|
|
34
|
+
return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
|
|
35
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from "react";
|
|
2
|
+
import { ScrollView, Text, View } from "react-native";
|
|
3
|
+
import { IconCheck, IconCopy } from "@tabler/icons-react-native";
|
|
4
|
+
import { cn } from "../cn";
|
|
5
|
+
import { sizeStyle, textStyle } from "../controlStyles";
|
|
6
|
+
import { useLargerText } from "../theme";
|
|
7
|
+
import { tokens } from "../tokens";
|
|
8
|
+
import { useScheme } from "../useScheme";
|
|
9
|
+
import { MONO_FONT, chatTextSize } from "./chat-text";
|
|
10
|
+
import { ChatIconButton } from "./icon-button";
|
|
11
|
+
|
|
12
|
+
export type CodeBlockCopyButtonProps = {
|
|
13
|
+
code: string;
|
|
14
|
+
/**
|
|
15
|
+
* Puts `code` on the clipboard. React Native's own `Clipboard` is
|
|
16
|
+
* deprecated, and native-ui adds no clipboard package, so the app passes its
|
|
17
|
+
* own (for example `expo-clipboard`'s `setStringAsync`). A rejected promise
|
|
18
|
+
* leaves the copy icon.
|
|
19
|
+
*/
|
|
20
|
+
onCopy: (code: string) => void | Promise<void>;
|
|
21
|
+
/** Milliseconds the check shows after a copy. Default 2000, as on the web. */
|
|
22
|
+
timeout?: number;
|
|
23
|
+
/** Default "Copy code"; "Copied" while the check shows. */
|
|
24
|
+
label?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** Ghost copy button: the copy icon, then a check for `timeout` after a copy. */
|
|
28
|
+
export function CodeBlockCopyButton({ code, onCopy, timeout = 2000, label = "Copy code" }: CodeBlockCopyButtonProps) {
|
|
29
|
+
const [copied, setCopied] = useState(false);
|
|
30
|
+
const timer = useRef<number | undefined>(undefined);
|
|
31
|
+
|
|
32
|
+
useEffect(() => () => clearTimeout(timer.current), []);
|
|
33
|
+
|
|
34
|
+
const copy = async () => {
|
|
35
|
+
// As the web: presses while the check shows do nothing.
|
|
36
|
+
if (copied) return;
|
|
37
|
+
try {
|
|
38
|
+
await onCopy(code);
|
|
39
|
+
} catch {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
setCopied(true);
|
|
43
|
+
timer.current = setTimeout(() => setCopied(false), timeout);
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<ChatIconButton
|
|
48
|
+
icon={copied ? IconCheck : IconCopy}
|
|
49
|
+
label={copied ? "Copied" : label}
|
|
50
|
+
onPress={() => void copy()}
|
|
51
|
+
/>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type CodeBlockProps = {
|
|
56
|
+
code: string;
|
|
57
|
+
/** Shown in the header. No highlighting: native-ui has no shiki. */
|
|
58
|
+
language?: string;
|
|
59
|
+
/** With it, the header has a copy button. See `CodeBlockCopyButton`. */
|
|
60
|
+
onCopy?: (code: string) => void | Promise<void>;
|
|
61
|
+
className?: string;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Plain code on the flat panel (web `flatPanelSubtle`: `bg-surface-secondary`,
|
|
66
|
+
* no border or shadow) at radius 16. A header with the language and a copy
|
|
67
|
+
* button when either is given. Long lines scroll sideways; the code is
|
|
68
|
+
* selectable.
|
|
69
|
+
*/
|
|
70
|
+
export function CodeBlock({ code, language, onCopy, className }: CodeBlockProps) {
|
|
71
|
+
const scheme = useScheme();
|
|
72
|
+
const largerText = useLargerText();
|
|
73
|
+
const size = chatTextSize("small", largerText);
|
|
74
|
+
const caption = chatTextSize("caption", largerText);
|
|
75
|
+
const header = Boolean(language || onCopy);
|
|
76
|
+
return (
|
|
77
|
+
<View
|
|
78
|
+
className={cn("w-full overflow-hidden", className)}
|
|
79
|
+
style={{ borderRadius: tokens.radius.lg, backgroundColor: tokens.color.surfaceSecondary[scheme] }}
|
|
80
|
+
>
|
|
81
|
+
{header ? (
|
|
82
|
+
<View className="min-h-11 flex-row items-center justify-between pl-3 pr-1.5">
|
|
83
|
+
<Text className={caption} style={[sizeStyle(caption), textStyle("muted", scheme), { fontFamily: MONO_FONT }]}>
|
|
84
|
+
{language ?? ""}
|
|
85
|
+
</Text>
|
|
86
|
+
{onCopy ? <CodeBlockCopyButton code={code} onCopy={onCopy} /> : null}
|
|
87
|
+
</View>
|
|
88
|
+
) : null}
|
|
89
|
+
<ScrollView
|
|
90
|
+
horizontal
|
|
91
|
+
showsHorizontalScrollIndicator={false}
|
|
92
|
+
contentContainerClassName={header ? "px-4 pb-4" : "p-4"}
|
|
93
|
+
>
|
|
94
|
+
<Text
|
|
95
|
+
selectable
|
|
96
|
+
className={size}
|
|
97
|
+
style={[sizeStyle(size), textStyle("foreground", scheme), { fontFamily: MONO_FONT }]}
|
|
98
|
+
>
|
|
99
|
+
{code}
|
|
100
|
+
</Text>
|
|
101
|
+
</ScrollView>
|
|
102
|
+
</View>
|
|
103
|
+
);
|
|
104
|
+
}
|