@vvedantb/native-ui 0.3.17 → 0.5.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 +146 -13
- package/package.json +10 -2
- package/src/CaptureButton.tsx +62 -0
- package/src/Card.tsx +37 -13
- package/src/ColorSwatches.tsx +133 -0
- package/src/DateTimeField.tsx +75 -19
- package/src/HeaderIconButton.tsx +18 -1
- package/src/IconButton.tsx +87 -0
- package/src/ListRow.tsx +104 -30
- package/src/PageIndicator.tsx +87 -0
- package/src/SectionLabel.tsx +48 -0
- package/src/SheetRow.tsx +89 -0
- package/src/StackPageHeader.tsx +100 -0
- package/src/StatGrid.tsx +80 -0
- package/src/StatusDot.tsx +14 -1
- package/src/ThemeToggle.tsx +17 -5
- 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/entries/date-time.ts +1 -0
- package/src/entries/layout.ts +6 -2
- package/src/entries/page-title.ts +7 -2
- package/src/entries/primitives.ts +19 -1
- package/src/examples/AiChatGallery.tsx +233 -0
- package/src/examples/ParityGallery.tsx +205 -3
- package/src/index.ts +19 -0
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createContext, useContext, type ComponentType } from "react";
|
|
2
|
+
import { View, type ViewProps } from "react-native";
|
|
3
|
+
import {
|
|
4
|
+
IconCopy,
|
|
5
|
+
IconRefresh,
|
|
6
|
+
IconThumbDown,
|
|
7
|
+
IconThumbUp,
|
|
8
|
+
type IconProps,
|
|
9
|
+
} from "@tabler/icons-react-native";
|
|
10
|
+
import { cn } from "../cn";
|
|
11
|
+
import { palette } from "../palette";
|
|
12
|
+
import { tokens } from "../tokens";
|
|
13
|
+
import { useScheme } from "../useScheme";
|
|
14
|
+
import { ChatIconButton } from "./icon-button";
|
|
15
|
+
import { ChatTextColorContext, ResponseText } from "./response-text";
|
|
16
|
+
|
|
17
|
+
export type MessageRole = "user" | "assistant" | "system";
|
|
18
|
+
|
|
19
|
+
const MessageContext = createContext<MessageRole>("assistant");
|
|
20
|
+
|
|
21
|
+
export type MessageProps = ViewProps & {
|
|
22
|
+
from: MessageRole;
|
|
23
|
+
className?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* One message: the web `w-full max-w-[95%] flex-col gap-2`. A user message
|
|
28
|
+
* sits at the end of the row with its parts at the end; an assistant message
|
|
29
|
+
* at the start, its parts full width.
|
|
30
|
+
*/
|
|
31
|
+
export function Message({ from, className, style, ...props }: MessageProps) {
|
|
32
|
+
const user = from === "user";
|
|
33
|
+
return (
|
|
34
|
+
<MessageContext.Provider value={from}>
|
|
35
|
+
<View
|
|
36
|
+
className={cn("w-full max-w-[95%] gap-2", className)}
|
|
37
|
+
style={[
|
|
38
|
+
{ alignSelf: user ? "flex-end" : "flex-start", alignItems: user ? "flex-end" : "stretch" },
|
|
39
|
+
style,
|
|
40
|
+
]}
|
|
41
|
+
{...props}
|
|
42
|
+
/>
|
|
43
|
+
</MessageContext.Provider>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The web user bubble: `rounded-2xl rounded-br-md bg-primary` (radius 16 with
|
|
49
|
+
* the bottom-right corner at 14, `tokens.radius.md`).
|
|
50
|
+
*/
|
|
51
|
+
export function userBubbleStyle(scheme: "light" | "dark") {
|
|
52
|
+
return {
|
|
53
|
+
backgroundColor: palette.primary[scheme],
|
|
54
|
+
borderRadius: tokens.radius.lg,
|
|
55
|
+
borderBottomRightRadius: tokens.radius.md,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export type MessageContentProps = ViewProps & { className?: string };
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The message body. A user message is a filled bubble (`px-4 py-3`, the
|
|
63
|
+
* primary fill and label colour); an assistant message has no fill and the
|
|
64
|
+
* foreground colour. A string child is drawn as `ResponseText`, and
|
|
65
|
+
* `ResponseText` inside takes the colour.
|
|
66
|
+
*/
|
|
67
|
+
export function MessageContent({ children, className, style, ...props }: MessageContentProps) {
|
|
68
|
+
const from = useContext(MessageContext);
|
|
69
|
+
const scheme = useScheme();
|
|
70
|
+
const user = from === "user";
|
|
71
|
+
const color = user ? palette.primaryForeground[scheme] : tokens.color.foreground[scheme];
|
|
72
|
+
return (
|
|
73
|
+
<ChatTextColorContext.Provider value={color}>
|
|
74
|
+
<View
|
|
75
|
+
className={cn("min-w-0 max-w-full gap-2", user && "px-4 py-3", className)}
|
|
76
|
+
style={[user ? userBubbleStyle(scheme) : null, style]}
|
|
77
|
+
{...props}
|
|
78
|
+
>
|
|
79
|
+
{typeof children === "string" ? <ResponseText>{children}</ResponseText> : children}
|
|
80
|
+
</View>
|
|
81
|
+
</ChatTextColorContext.Provider>
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type MessageActionsBarProps = ViewProps & { className?: string };
|
|
86
|
+
|
|
87
|
+
/** A row of `MessageAction`s under a message, as the web `flex items-center gap-1`. */
|
|
88
|
+
export function MessageActionsBar({ className, ...props }: MessageActionsBarProps) {
|
|
89
|
+
return <View className={cn("flex-row items-center gap-1", className)} {...props} />;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The usual actions, with their icons and accessibility labels. */
|
|
93
|
+
export const MESSAGE_ACTIONS = {
|
|
94
|
+
copy: { icon: IconCopy, label: "Copy" },
|
|
95
|
+
retry: { icon: IconRefresh, label: "Retry" },
|
|
96
|
+
like: { icon: IconThumbUp, label: "Good response" },
|
|
97
|
+
dislike: { icon: IconThumbDown, label: "Bad response" },
|
|
98
|
+
} as const;
|
|
99
|
+
|
|
100
|
+
export type MessageActionKind = keyof typeof MESSAGE_ACTIONS;
|
|
101
|
+
|
|
102
|
+
export type MessageActionProps = {
|
|
103
|
+
/** A preset icon and label from `MESSAGE_ACTIONS`. `icon` and `label` override it. */
|
|
104
|
+
kind?: MessageActionKind;
|
|
105
|
+
icon?: ComponentType<IconProps>;
|
|
106
|
+
/** The accessibility label (the web `label` / `tooltip`). Required without `kind`. */
|
|
107
|
+
label?: string;
|
|
108
|
+
onPress?: () => void;
|
|
109
|
+
/**
|
|
110
|
+
* For feedback: the chosen action draws its icon in the foreground and
|
|
111
|
+
* reports `selected` to VoiceOver and TalkBack.
|
|
112
|
+
*/
|
|
113
|
+
selected?: boolean;
|
|
114
|
+
disabled?: boolean;
|
|
115
|
+
testID?: string;
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* A ghost icon button under a message: the web `icon-sm` (32, 16 icon) with
|
|
120
|
+
* the slop to 44. Copying is up to the app (`onPress`): React Native's own
|
|
121
|
+
* `Clipboard` is deprecated, and native-ui adds no clipboard package.
|
|
122
|
+
*/
|
|
123
|
+
export function MessageAction({
|
|
124
|
+
kind,
|
|
125
|
+
icon,
|
|
126
|
+
label,
|
|
127
|
+
onPress,
|
|
128
|
+
selected = false,
|
|
129
|
+
disabled,
|
|
130
|
+
testID,
|
|
131
|
+
}: MessageActionProps) {
|
|
132
|
+
const scheme = useScheme();
|
|
133
|
+
const preset = kind ? MESSAGE_ACTIONS[kind] : undefined;
|
|
134
|
+
const Icon = icon ?? preset?.icon ?? IconCopy;
|
|
135
|
+
return (
|
|
136
|
+
<ChatIconButton
|
|
137
|
+
icon={Icon}
|
|
138
|
+
label={label ?? preset?.label ?? ""}
|
|
139
|
+
iconColor={selected ? tokens.color.foreground[scheme] : undefined}
|
|
140
|
+
accessibilityState={{ selected }}
|
|
141
|
+
onPress={onPress}
|
|
142
|
+
disabled={disabled}
|
|
143
|
+
testID={testID}
|
|
144
|
+
/>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { KeyboardAvoidingView, View } from "react-native";
|
|
3
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
4
|
+
import { IconArrowUp, IconPlayerStopFilled } from "@tabler/icons-react-native";
|
|
5
|
+
import { cn } from "../cn";
|
|
6
|
+
import { fieldStyle, fieldTextStyle, typeStyle } from "../controlStyles";
|
|
7
|
+
import { Input as FieldInput } from "../primitives/input";
|
|
8
|
+
import { useControllableState } from "../primitives/use-controllable-state";
|
|
9
|
+
import { useLargerText } from "../theme";
|
|
10
|
+
import { useScheme } from "../useScheme";
|
|
11
|
+
import { ChatIconButton, type ChatIconButtonProps } from "./icon-button";
|
|
12
|
+
|
|
13
|
+
/** The `ai` SDK chat status the web `PromptInputSubmit` takes. */
|
|
14
|
+
export type ChatStatus = "ready" | "submitted" | "streaming" | "error";
|
|
15
|
+
|
|
16
|
+
/** Whether the AI is answering: `submitted` (waiting) or `streaming`. */
|
|
17
|
+
export function isGenerating(status: ChatStatus | undefined) {
|
|
18
|
+
return status === "submitted" || status === "streaming";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type PromptInputSubmitProps = {
|
|
22
|
+
status?: ChatStatus;
|
|
23
|
+
onSubmit?: () => void;
|
|
24
|
+
onStop?: () => void;
|
|
25
|
+
/** Whether there is something to send. Default `true`. */
|
|
26
|
+
canSubmit?: boolean;
|
|
27
|
+
testID?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Send, or stop while the AI answers: the web `default` `icon-sm` button
|
|
32
|
+
* (32, round, slop to 44). Send is an up arrow on the primary fill, and the
|
|
33
|
+
* `secondary` fill, disabled, with nothing to send. While `status` is
|
|
34
|
+
* `submitted` or `streaming` it is a filled square labelled "Stop" that calls
|
|
35
|
+
* `onStop` (disabled without one).
|
|
36
|
+
*/
|
|
37
|
+
export function PromptInputSubmit({
|
|
38
|
+
status,
|
|
39
|
+
onSubmit,
|
|
40
|
+
onStop,
|
|
41
|
+
canSubmit = true,
|
|
42
|
+
testID,
|
|
43
|
+
}: PromptInputSubmitProps) {
|
|
44
|
+
if (isGenerating(status)) {
|
|
45
|
+
return (
|
|
46
|
+
<ChatIconButton
|
|
47
|
+
icon={IconPlayerStopFilled}
|
|
48
|
+
label="Stop"
|
|
49
|
+
variant="primary"
|
|
50
|
+
onPress={onStop}
|
|
51
|
+
disabled={!onStop}
|
|
52
|
+
testID={testID}
|
|
53
|
+
/>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return (
|
|
57
|
+
<ChatIconButton
|
|
58
|
+
icon={IconArrowUp}
|
|
59
|
+
label="Send"
|
|
60
|
+
variant={canSubmit ? "primary" : "secondary"}
|
|
61
|
+
onPress={onSubmit}
|
|
62
|
+
disabled={!canSubmit}
|
|
63
|
+
testID={testID}
|
|
64
|
+
/>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export type PromptInputButtonProps = ChatIconButtonProps;
|
|
69
|
+
|
|
70
|
+
/** A ghost tool button for the composer `toolbar`, such as attach (web `PromptInputButton`). Give it `icon` and `label`. */
|
|
71
|
+
export function PromptInputButton(props: PromptInputButtonProps) {
|
|
72
|
+
return <ChatIconButton variant="ghost" {...props} />;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export type PromptInputProps = {
|
|
76
|
+
value?: string;
|
|
77
|
+
defaultValue?: string;
|
|
78
|
+
onChangeText?: (text: string) => void;
|
|
79
|
+
/** Called with the trimmed text when Send is pressed. The field then clears. */
|
|
80
|
+
onSubmit: (text: string) => void;
|
|
81
|
+
status?: ChatStatus;
|
|
82
|
+
onStop?: () => void;
|
|
83
|
+
/** Default "What would you like to know?", as on the web. */
|
|
84
|
+
placeholder?: string;
|
|
85
|
+
/** Names the field for VoiceOver and TalkBack. Default "Message". */
|
|
86
|
+
accessibilityLabel?: string;
|
|
87
|
+
/** Above the field, such as `Attachments`. */
|
|
88
|
+
header?: ReactNode;
|
|
89
|
+
/** Before the send button, such as `PromptInputButton`s. */
|
|
90
|
+
toolbar?: ReactNode;
|
|
91
|
+
disabled?: boolean;
|
|
92
|
+
/** Pad the bottom safe-area inset (home indicator). Default `true`; turn it off when a tab bar below already does. */
|
|
93
|
+
safeBottom?: boolean;
|
|
94
|
+
/** Height of anything above the screen's own top, such as a navigation header, for the `KeyboardAvoidingView`. Default 0. */
|
|
95
|
+
keyboardVerticalOffset?: number;
|
|
96
|
+
className?: string;
|
|
97
|
+
testID?: string;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/** The field grows to this height, then scrolls: about 6 lines of `text-base` (24 each, with `py-2.5`), and about 8 of `text-xl` with larger text. */
|
|
101
|
+
const MAX_FIELD_HEIGHT = { regular: 160, larger: 240 } as const;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The chat composer: the web `PromptInput` field (`rounded-field border
|
|
105
|
+
* bg-field-background`, `fieldStyle`) with an optional `header` row, a
|
|
106
|
+
* multiline field that grows from one line, and a footer of `toolbar` tools
|
|
107
|
+
* and the send / stop button. It sits in a `KeyboardAvoidingView` (padding,
|
|
108
|
+
* on iOS and Android) and pads the safe-area insets, so put it last in a
|
|
109
|
+
* full-height column, under the `Conversation`.
|
|
110
|
+
*/
|
|
111
|
+
export function PromptInput({
|
|
112
|
+
value,
|
|
113
|
+
defaultValue = "",
|
|
114
|
+
onChangeText,
|
|
115
|
+
onSubmit,
|
|
116
|
+
status,
|
|
117
|
+
onStop,
|
|
118
|
+
placeholder = "What would you like to know?",
|
|
119
|
+
accessibilityLabel = "Message",
|
|
120
|
+
header,
|
|
121
|
+
toolbar,
|
|
122
|
+
disabled = false,
|
|
123
|
+
safeBottom = true,
|
|
124
|
+
keyboardVerticalOffset = 0,
|
|
125
|
+
className,
|
|
126
|
+
testID,
|
|
127
|
+
}: PromptInputProps) {
|
|
128
|
+
const scheme = useScheme();
|
|
129
|
+
const largerText = useLargerText();
|
|
130
|
+
const insets = useSafeAreaInsets();
|
|
131
|
+
const [text, setText] = useControllableState({ value, defaultValue, onChange: onChangeText });
|
|
132
|
+
const message = text.trim();
|
|
133
|
+
const canSubmit = message.length > 0 && !disabled && !isGenerating(status);
|
|
134
|
+
const bottom = safeBottom ? insets.bottom : 0;
|
|
135
|
+
|
|
136
|
+
const submit = () => {
|
|
137
|
+
if (!canSubmit) return;
|
|
138
|
+
onSubmit(message);
|
|
139
|
+
setText("");
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
return (
|
|
143
|
+
<KeyboardAvoidingView
|
|
144
|
+
behavior="padding"
|
|
145
|
+
// The keyboard covers the home indicator, so the inset padded below is
|
|
146
|
+
// taken off the lift: the composer sits right on the keyboard.
|
|
147
|
+
keyboardVerticalOffset={keyboardVerticalOffset - bottom}
|
|
148
|
+
>
|
|
149
|
+
<View
|
|
150
|
+
testID={testID}
|
|
151
|
+
className={cn("pt-2", className)}
|
|
152
|
+
style={{ paddingBottom: bottom + 8, paddingLeft: insets.left + 12, paddingRight: insets.right + 12 }}
|
|
153
|
+
>
|
|
154
|
+
<View className="border" style={fieldStyle(scheme)}>
|
|
155
|
+
{header ? <View className="flex-row flex-wrap gap-1 px-2 pt-2">{header}</View> : null}
|
|
156
|
+
{/* The `Textarea` field without its `numberOfLines` (default 4), which on Android sets the line count: this one starts at one line and grows. */}
|
|
157
|
+
<FieldInput
|
|
158
|
+
multiline
|
|
159
|
+
value={text}
|
|
160
|
+
onChangeText={setText}
|
|
161
|
+
placeholder={placeholder}
|
|
162
|
+
accessibilityLabel={accessibilityLabel}
|
|
163
|
+
editable={!disabled}
|
|
164
|
+
textAlignVertical="top"
|
|
165
|
+
className={cn(
|
|
166
|
+
"min-h-11 border-0 px-3.5 py-2.5 text-base font-instrument-sans",
|
|
167
|
+
largerText && "min-h-14 px-4 py-3 text-xl font-instrument-sans-medium",
|
|
168
|
+
)}
|
|
169
|
+
style={[
|
|
170
|
+
fieldTextStyle(scheme),
|
|
171
|
+
largerText ? typeStyle("medium") : null,
|
|
172
|
+
{
|
|
173
|
+
backgroundColor: "transparent",
|
|
174
|
+
maxHeight: largerText ? MAX_FIELD_HEIGHT.larger : MAX_FIELD_HEIGHT.regular,
|
|
175
|
+
},
|
|
176
|
+
]}
|
|
177
|
+
/>
|
|
178
|
+
<View className="flex-row items-center justify-between gap-1 px-2 pb-2">
|
|
179
|
+
<View className="min-w-0 shrink flex-row items-center gap-1">{toolbar}</View>
|
|
180
|
+
<PromptInputSubmit status={status} onSubmit={submit} onStop={onStop} canSubmit={canSubmit} />
|
|
181
|
+
</View>
|
|
182
|
+
</View>
|
|
183
|
+
</View>
|
|
184
|
+
</KeyboardAvoidingView>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { Text, View } from "react-native";
|
|
3
|
+
import { IconBrain } from "@tabler/icons-react-native";
|
|
4
|
+
import { cn } from "../cn";
|
|
5
|
+
import { sizeStyle, textStyle } from "../controlStyles";
|
|
6
|
+
import { withAlpha } from "../palette";
|
|
7
|
+
import { useControllableState } from "../primitives/use-controllable-state";
|
|
8
|
+
import { useLargerText } from "../theme";
|
|
9
|
+
import { tokens } from "../tokens";
|
|
10
|
+
import { useScheme } from "../useScheme";
|
|
11
|
+
import { MONO_FONT, chatTextSize, formatElapsed } from "./chat-text";
|
|
12
|
+
import {
|
|
13
|
+
Collapsible,
|
|
14
|
+
CollapsibleChevron,
|
|
15
|
+
CollapsibleContent,
|
|
16
|
+
CollapsibleTrigger,
|
|
17
|
+
type CollapsibleProps,
|
|
18
|
+
type CollapsibleTriggerProps,
|
|
19
|
+
} from "./collapsible";
|
|
20
|
+
import { ResponseText } from "./response-text";
|
|
21
|
+
import { Shimmer } from "./shimmer";
|
|
22
|
+
|
|
23
|
+
type ReasoningContextValue = {
|
|
24
|
+
isStreaming: boolean;
|
|
25
|
+
isOpen: boolean;
|
|
26
|
+
setIsOpen: (open: boolean) => void;
|
|
27
|
+
/** Whole seconds the reasoning streamed for, or `undefined` before it is known. */
|
|
28
|
+
duration: number | undefined;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
|
32
|
+
|
|
33
|
+
export function useReasoning() {
|
|
34
|
+
const context = useContext(ReasoningContext);
|
|
35
|
+
if (!context) throw new Error("Reasoning components must be used within Reasoning");
|
|
36
|
+
return context;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const AUTO_CLOSE_DELAY_MS = 1000;
|
|
40
|
+
|
|
41
|
+
export type ReasoningProps = Omit<CollapsibleProps, "open" | "defaultOpen" | "onOpenChange"> & {
|
|
42
|
+
isStreaming?: boolean;
|
|
43
|
+
open?: boolean;
|
|
44
|
+
/** Default: open while streaming. `false` keeps it shut when streaming starts, as on the web. */
|
|
45
|
+
defaultOpen?: boolean;
|
|
46
|
+
onOpenChange?: (open: boolean) => void;
|
|
47
|
+
/** Seconds, when the app knows them (a reloaded thread). Default: timed from `isStreaming`. */
|
|
48
|
+
duration?: number;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The AI's reasoning, folded. As on the web it opens when streaming starts,
|
|
53
|
+
* times the stream, and closes once, a second after it stops. Unlike the web,
|
|
54
|
+
* a reader can close it while it streams (the web reopens it), and a toggle
|
|
55
|
+
* after the stream cancels the pending close, as `useStreamingDisclosure`.
|
|
56
|
+
*/
|
|
57
|
+
export function Reasoning({
|
|
58
|
+
isStreaming = false,
|
|
59
|
+
open,
|
|
60
|
+
defaultOpen,
|
|
61
|
+
onOpenChange,
|
|
62
|
+
duration,
|
|
63
|
+
className,
|
|
64
|
+
...props
|
|
65
|
+
}: ReasoningProps) {
|
|
66
|
+
const [isOpen, setOpen] = useControllableState({
|
|
67
|
+
value: open,
|
|
68
|
+
defaultValue: defaultOpen ?? isStreaming,
|
|
69
|
+
onChange: onOpenChange,
|
|
70
|
+
});
|
|
71
|
+
const [measured, setMeasured] = useState<number | undefined>(undefined);
|
|
72
|
+
const [closeDue, setCloseDue] = useState(false);
|
|
73
|
+
const startedAt = useRef<number | null>(isStreaming ? Date.now() : null);
|
|
74
|
+
|
|
75
|
+
// Runs when the stream starts or stops, not on every open change, so a
|
|
76
|
+
// reader's own close mid-stream holds.
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (isStreaming) {
|
|
79
|
+
if (startedAt.current === null) startedAt.current = Date.now();
|
|
80
|
+
if (!isOpen && defaultOpen !== false) setOpen(true);
|
|
81
|
+
setCloseDue(false);
|
|
82
|
+
} else if (startedAt.current !== null) {
|
|
83
|
+
setMeasured(Math.ceil((Date.now() - startedAt.current) / 1000));
|
|
84
|
+
startedAt.current = null;
|
|
85
|
+
if (isOpen) setCloseDue(true);
|
|
86
|
+
}
|
|
87
|
+
}, [isStreaming]);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!closeDue) return;
|
|
91
|
+
const timer = setTimeout(() => {
|
|
92
|
+
setOpen(false);
|
|
93
|
+
setCloseDue(false);
|
|
94
|
+
}, AUTO_CLOSE_DELAY_MS);
|
|
95
|
+
return () => clearTimeout(timer);
|
|
96
|
+
}, [closeDue]);
|
|
97
|
+
|
|
98
|
+
const setIsOpen = (next: boolean) => {
|
|
99
|
+
setCloseDue(false);
|
|
100
|
+
setOpen(next);
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
return (
|
|
104
|
+
<ReasoningContext.Provider
|
|
105
|
+
value={{ isStreaming, isOpen, setIsOpen, duration: duration ?? measured }}
|
|
106
|
+
>
|
|
107
|
+
<Collapsible
|
|
108
|
+
open={isOpen}
|
|
109
|
+
onOpenChange={setIsOpen}
|
|
110
|
+
className={cn("mb-4", className)}
|
|
111
|
+
{...props}
|
|
112
|
+
/>
|
|
113
|
+
</ReasoningContext.Provider>
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** "Thinking…" while streaming, then "Thought for 12s" ("Thought for a few seconds" with no duration). */
|
|
118
|
+
export function reasoningMessage(isStreaming: boolean, duration?: number): string {
|
|
119
|
+
if (isStreaming) return "Thinking…";
|
|
120
|
+
if (duration === undefined) return "Thought for a few seconds";
|
|
121
|
+
return `Thought for ${formatElapsed(duration)}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export type ReasoningTriggerProps = CollapsibleTriggerProps & {
|
|
125
|
+
/** Replaces `reasoningMessage`. A string, as it is also the accessibility label. */
|
|
126
|
+
getThinkingMessage?: (isStreaming: boolean, duration?: number) => string;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
/** Brain icon, the shimmering "Thinking…" or "Thought for 12s", and a chevron: the web `ReasoningTrigger`. */
|
|
130
|
+
export function ReasoningTrigger({
|
|
131
|
+
children,
|
|
132
|
+
getThinkingMessage = reasoningMessage,
|
|
133
|
+
className,
|
|
134
|
+
...props
|
|
135
|
+
}: ReasoningTriggerProps) {
|
|
136
|
+
const { isStreaming, isOpen, duration } = useReasoning();
|
|
137
|
+
const scheme = useScheme();
|
|
138
|
+
const largerText = useLargerText();
|
|
139
|
+
const message = getThinkingMessage(isStreaming, duration);
|
|
140
|
+
const muted = tokens.color.muted[scheme];
|
|
141
|
+
const iconSize = largerText ? 20 : 16;
|
|
142
|
+
return (
|
|
143
|
+
<CollapsibleTrigger
|
|
144
|
+
accessibilityLabel={message}
|
|
145
|
+
className={cn("w-full flex-row items-center gap-2 py-1", className)}
|
|
146
|
+
{...props}
|
|
147
|
+
>
|
|
148
|
+
{children ?? (
|
|
149
|
+
<>
|
|
150
|
+
<IconBrain size={iconSize} color={muted} />
|
|
151
|
+
{isStreaming ? (
|
|
152
|
+
<Shimmer duration={1} numberOfLines={1}>
|
|
153
|
+
{message}
|
|
154
|
+
</Shimmer>
|
|
155
|
+
) : (
|
|
156
|
+
<ResponseText size="small" tone="muted" selectable={false} numberOfLines={1} className="shrink">
|
|
157
|
+
{message}
|
|
158
|
+
</ResponseText>
|
|
159
|
+
)}
|
|
160
|
+
<CollapsibleChevron open={isOpen} size={iconSize} color={muted} />
|
|
161
|
+
</>
|
|
162
|
+
)}
|
|
163
|
+
</CollapsibleTrigger>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export type ReasoningContentProps = {
|
|
168
|
+
children: string;
|
|
169
|
+
className?: string;
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* The reasoning text, muted and monospaced, on the web `rounded-lg
|
|
174
|
+
* bg-surface-secondary/40 p-3` panel. Plain text: see `ResponseText` for
|
|
175
|
+
* markdown.
|
|
176
|
+
*/
|
|
177
|
+
export function ReasoningContent({ children, className }: ReasoningContentProps) {
|
|
178
|
+
const scheme = useScheme();
|
|
179
|
+
const largerText = useLargerText();
|
|
180
|
+
const size = chatTextSize("small", largerText);
|
|
181
|
+
return (
|
|
182
|
+
<CollapsibleContent className={cn("pb-1 pt-2", className)}>
|
|
183
|
+
<View
|
|
184
|
+
className="p-3"
|
|
185
|
+
style={{
|
|
186
|
+
borderRadius: tokens.radius.lg,
|
|
187
|
+
backgroundColor: withAlpha(tokens.color.surfaceSecondary[scheme], 0.4),
|
|
188
|
+
}}
|
|
189
|
+
>
|
|
190
|
+
<Text
|
|
191
|
+
selectable
|
|
192
|
+
className={size}
|
|
193
|
+
style={[sizeStyle(size), textStyle("muted", scheme), { fontFamily: MONO_FONT }]}
|
|
194
|
+
>
|
|
195
|
+
{children}
|
|
196
|
+
</Text>
|
|
197
|
+
</View>
|
|
198
|
+
</CollapsibleContent>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
import { Text, type TextProps } from "react-native";
|
|
3
|
+
import { cn } from "../cn";
|
|
4
|
+
import { sizeStyle, textStyle } from "../controlStyles";
|
|
5
|
+
import { useLargerText } from "../theme";
|
|
6
|
+
import { useScheme } from "../useScheme";
|
|
7
|
+
import { chatTextSize, type ChatTextKind } from "./chat-text";
|
|
8
|
+
|
|
9
|
+
// Markdown stub. The web renders replies with Streamdown; native-ui has no
|
|
10
|
+
// markdown peer, so `ResponseText` draws the text as it is, newlines kept.
|
|
11
|
+
// An app that wants markdown can render one of these in its place:
|
|
12
|
+
// `react-native-markdown-display`, `@ronradtke/react-native-markdown-display`
|
|
13
|
+
// (a maintained fork) or `react-native-marked`.
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The text colour `MessageContent` hands down: React Native text does not
|
|
17
|
+
* inherit a colour from its parent view, as web text does from `color`.
|
|
18
|
+
*/
|
|
19
|
+
export const ChatTextColorContext = createContext<string | undefined>(undefined);
|
|
20
|
+
|
|
21
|
+
export type ResponseTextProps = Omit<TextProps, "children"> & {
|
|
22
|
+
children: string;
|
|
23
|
+
/** Default: the enclosing `MessageContent` colour, else the foreground. */
|
|
24
|
+
tone?: "foreground" | "muted";
|
|
25
|
+
/** Default `"body"` (16, `text-xl` with larger text). See `chatTextSize`. */
|
|
26
|
+
size?: ChatTextKind;
|
|
27
|
+
className?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** Plain reply text, selectable, newlines kept. The markdown placeholder. */
|
|
31
|
+
export function ResponseText({
|
|
32
|
+
children,
|
|
33
|
+
tone,
|
|
34
|
+
size = "body",
|
|
35
|
+
selectable = true,
|
|
36
|
+
className,
|
|
37
|
+
style,
|
|
38
|
+
...props
|
|
39
|
+
}: ResponseTextProps) {
|
|
40
|
+
const scheme = useScheme();
|
|
41
|
+
const largerText = useLargerText();
|
|
42
|
+
const inherited = useContext(ChatTextColorContext);
|
|
43
|
+
const textSize = chatTextSize(size, largerText);
|
|
44
|
+
const color =
|
|
45
|
+
tone || !inherited ? textStyle(tone ?? "foreground", scheme) : { color: inherited };
|
|
46
|
+
return (
|
|
47
|
+
<Text
|
|
48
|
+
selectable={selectable}
|
|
49
|
+
className={cn(textSize, "font-instrument-sans", className)}
|
|
50
|
+
style={[sizeStyle(textSize), color, style]}
|
|
51
|
+
{...props}
|
|
52
|
+
>
|
|
53
|
+
{children}
|
|
54
|
+
</Text>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { useEffect } from "react";
|
|
2
|
+
import { Text, type StyleProp, type TextStyle } from "react-native";
|
|
3
|
+
import Animated, {
|
|
4
|
+
cancelAnimation,
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
useSharedValue,
|
|
7
|
+
withRepeat,
|
|
8
|
+
withTiming,
|
|
9
|
+
} from "react-native-reanimated";
|
|
10
|
+
import { cn } from "../cn";
|
|
11
|
+
import { sizeStyle, textStyle } from "../controlStyles";
|
|
12
|
+
import { useLargerText } from "../theme";
|
|
13
|
+
import { useReduceMotion } from "../useReduceMotion";
|
|
14
|
+
import { useScheme } from "../useScheme";
|
|
15
|
+
import { chatTextSize, type ChatTextKind } from "./chat-text";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Lowest opacity of the pulse. The foreground at 0.45 on the page is close to
|
|
19
|
+
* the muted colour, the far end of the web sweep.
|
|
20
|
+
*/
|
|
21
|
+
export const SHIMMER_LOW_OPACITY = 0.45;
|
|
22
|
+
|
|
23
|
+
export type ShimmerProps = {
|
|
24
|
+
children: string;
|
|
25
|
+
/** Seconds for one pass, as on the web. Default 2. */
|
|
26
|
+
duration?: number;
|
|
27
|
+
/** Default `"small"` (14, `text-lg` with larger text). */
|
|
28
|
+
size?: ChatTextKind;
|
|
29
|
+
numberOfLines?: number;
|
|
30
|
+
className?: string;
|
|
31
|
+
style?: StyleProp<TextStyle>;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* "Live" text, such as "Thinking…". The web sweeps a foreground band across
|
|
36
|
+
* muted text (`bg-clip-text`), which React Native cannot clip to the glyphs
|
|
37
|
+
* without a masked view. Here the foreground text fades to
|
|
38
|
+
* `SHIMMER_LOW_OPACITY` and back once per `duration`, on the UI thread with
|
|
39
|
+
* Reanimated. With Reduce Motion it holds still in the muted colour.
|
|
40
|
+
*/
|
|
41
|
+
export function Shimmer({
|
|
42
|
+
children,
|
|
43
|
+
duration = 2,
|
|
44
|
+
size = "small",
|
|
45
|
+
numberOfLines,
|
|
46
|
+
className,
|
|
47
|
+
style,
|
|
48
|
+
}: ShimmerProps) {
|
|
49
|
+
const scheme = useScheme();
|
|
50
|
+
const largerText = useLargerText();
|
|
51
|
+
const reduceMotion = useReduceMotion();
|
|
52
|
+
const opacity = useSharedValue(1);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (reduceMotion) {
|
|
56
|
+
cancelAnimation(opacity);
|
|
57
|
+
opacity.value = 1;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
opacity.value = withRepeat(
|
|
61
|
+
withTiming(SHIMMER_LOW_OPACITY, { duration: (duration * 1000) / 2 }),
|
|
62
|
+
-1,
|
|
63
|
+
true,
|
|
64
|
+
);
|
|
65
|
+
return () => cancelAnimation(opacity);
|
|
66
|
+
}, [opacity, reduceMotion, duration]);
|
|
67
|
+
|
|
68
|
+
const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value }));
|
|
69
|
+
const textSize = chatTextSize(size, largerText);
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<Animated.View className="min-w-0 shrink" style={animatedStyle}>
|
|
73
|
+
<Text
|
|
74
|
+
numberOfLines={numberOfLines}
|
|
75
|
+
className={cn(textSize, "font-instrument-sans", className)}
|
|
76
|
+
style={[
|
|
77
|
+
sizeStyle(textSize),
|
|
78
|
+
textStyle(reduceMotion ? "muted" : "foreground", scheme),
|
|
79
|
+
style,
|
|
80
|
+
]}
|
|
81
|
+
>
|
|
82
|
+
{children}
|
|
83
|
+
</Text>
|
|
84
|
+
</Animated.View>
|
|
85
|
+
);
|
|
86
|
+
}
|