@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,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
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
import { Pressable, View, type PressableProps, type ViewProps } from "react-native";
|
|
3
|
+
import { IconChevronDown, IconChevronRight } from "@tabler/icons-react-native";
|
|
4
|
+
import { FadeIn } from "react-native-reanimated";
|
|
5
|
+
import { cn } from "../cn";
|
|
6
|
+
import { MIN_TOUCH_TARGET, touchSlop } from "../controlStyles";
|
|
7
|
+
import { NativeOnlyAnimatedView } from "../primitives/native-only-animated-view";
|
|
8
|
+
import { useControllableState } from "../primitives/use-controllable-state";
|
|
9
|
+
|
|
10
|
+
// A small fold for the AI parts: native-ui has no `Collapsible`. A `Pressable`
|
|
11
|
+
// trigger and content that mounts only while open. The content fades in on
|
|
12
|
+
// iOS and Android, and appears in place with Reduce Motion.
|
|
13
|
+
|
|
14
|
+
type CollapsibleContextValue = { open: boolean; setOpen: (open: boolean) => void };
|
|
15
|
+
|
|
16
|
+
const CollapsibleContext = createContext<CollapsibleContextValue | null>(null);
|
|
17
|
+
|
|
18
|
+
/** The open state of the enclosing `Collapsible`, for a chevron or a custom trigger. */
|
|
19
|
+
export function useCollapsible() {
|
|
20
|
+
const context = useContext(CollapsibleContext);
|
|
21
|
+
if (!context) throw new Error("Collapsible parts must be used within Collapsible");
|
|
22
|
+
return context;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export type CollapsibleProps = ViewProps & {
|
|
26
|
+
open?: boolean;
|
|
27
|
+
defaultOpen?: boolean;
|
|
28
|
+
onOpenChange?: (open: boolean) => void;
|
|
29
|
+
className?: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** Holds its own open state, or takes `open` and `onOpenChange`, as the web Radix `Collapsible`. */
|
|
33
|
+
export function Collapsible({ open, defaultOpen = false, onOpenChange, ...props }: CollapsibleProps) {
|
|
34
|
+
const [isOpen, setOpen] = useControllableState({
|
|
35
|
+
value: open,
|
|
36
|
+
defaultValue: defaultOpen,
|
|
37
|
+
onChange: onOpenChange,
|
|
38
|
+
});
|
|
39
|
+
return (
|
|
40
|
+
<CollapsibleContext.Provider value={{ open: isOpen, setOpen }}>
|
|
41
|
+
<View {...props} />
|
|
42
|
+
</CollapsibleContext.Provider>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A `text-sm` line with the web `py-1`: 28 high. The slop brings it to 44. */
|
|
47
|
+
const TRIGGER_ROW_HEIGHT = 28;
|
|
48
|
+
|
|
49
|
+
export type CollapsibleTriggerProps = Omit<PressableProps, "children"> & {
|
|
50
|
+
className?: string;
|
|
51
|
+
children?: ReactNode;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** Toggles the fold. A button with `expanded` state for VoiceOver and TalkBack. */
|
|
55
|
+
export function CollapsibleTrigger({
|
|
56
|
+
className,
|
|
57
|
+
onPress,
|
|
58
|
+
accessibilityState,
|
|
59
|
+
...props
|
|
60
|
+
}: CollapsibleTriggerProps) {
|
|
61
|
+
const { open, setOpen } = useCollapsible();
|
|
62
|
+
return (
|
|
63
|
+
<Pressable
|
|
64
|
+
accessibilityRole="button"
|
|
65
|
+
hitSlop={touchSlop(MIN_TOUCH_TARGET, TRIGGER_ROW_HEIGHT)}
|
|
66
|
+
className={cn("active:opacity-70", className)}
|
|
67
|
+
{...props}
|
|
68
|
+
accessibilityState={{ ...accessibilityState, expanded: open }}
|
|
69
|
+
onPress={(event) => {
|
|
70
|
+
onPress?.(event);
|
|
71
|
+
setOpen(!open);
|
|
72
|
+
}}
|
|
73
|
+
/>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Mounted only while the fold is open. */
|
|
78
|
+
export function CollapsibleContent({ children, ...props }: ViewProps & { className?: string }) {
|
|
79
|
+
const { open } = useCollapsible();
|
|
80
|
+
if (!open) return null;
|
|
81
|
+
return (
|
|
82
|
+
<NativeOnlyAnimatedView entering={FadeIn.duration(150)} {...props}>
|
|
83
|
+
{children}
|
|
84
|
+
</NativeOnlyAnimatedView>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The fold chevron: `down` turns up when open (reasoning, sources, chain of
|
|
90
|
+
* thought), `right` turns down (trace lines), as the web `rotate-180` and
|
|
91
|
+
* `rotate-90`. It turns at once; the web eases it over 150 ms.
|
|
92
|
+
*/
|
|
93
|
+
export function CollapsibleChevron({
|
|
94
|
+
open,
|
|
95
|
+
variant = "down",
|
|
96
|
+
size,
|
|
97
|
+
color,
|
|
98
|
+
}: {
|
|
99
|
+
open: boolean;
|
|
100
|
+
variant?: "down" | "right";
|
|
101
|
+
size: number;
|
|
102
|
+
color: string;
|
|
103
|
+
}) {
|
|
104
|
+
const Icon = variant === "down" ? IconChevronDown : IconChevronRight;
|
|
105
|
+
const turn = variant === "down" ? "180deg" : "90deg";
|
|
106
|
+
return (
|
|
107
|
+
<View style={{ transform: [{ rotate: open ? turn : "0deg" }] }}>
|
|
108
|
+
<Icon size={size} color={color} />
|
|
109
|
+
</View>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useContext,
|
|
4
|
+
useRef,
|
|
5
|
+
useState,
|
|
6
|
+
type ComponentType,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
type RefObject,
|
|
9
|
+
} from "react";
|
|
10
|
+
import {
|
|
11
|
+
Platform,
|
|
12
|
+
ScrollView,
|
|
13
|
+
Text,
|
|
14
|
+
View,
|
|
15
|
+
type NativeScrollEvent,
|
|
16
|
+
type ScrollViewProps,
|
|
17
|
+
type ViewProps,
|
|
18
|
+
} from "react-native";
|
|
19
|
+
import { IconArrowDown, type IconProps } from "@tabler/icons-react-native";
|
|
20
|
+
import { FadeIn, FadeOut } from "react-native-reanimated";
|
|
21
|
+
import { Button } from "../Button";
|
|
22
|
+
import { cn } from "../cn";
|
|
23
|
+
import { sizeStyle, textStyle, typeStyle } from "../controlStyles";
|
|
24
|
+
import { NativeOnlyAnimatedView } from "../primitives/native-only-animated-view";
|
|
25
|
+
import { useLargerText } from "../theme";
|
|
26
|
+
import { tokens } from "../tokens";
|
|
27
|
+
import { useReduceMotion } from "../useReduceMotion";
|
|
28
|
+
import { useScheme } from "../useScheme";
|
|
29
|
+
import { chatTextSize } from "./chat-text";
|
|
30
|
+
|
|
31
|
+
/** Within this many points of the end, the thread counts as at the bottom and keeps following new content. */
|
|
32
|
+
export const AT_BOTTOM_THRESHOLD = 70;
|
|
33
|
+
|
|
34
|
+
/** Whether a scroll position is within `AT_BOTTOM_THRESHOLD` of the end. Content shorter than the view is at the bottom. */
|
|
35
|
+
export function isScrolledToBottom({ contentOffset, contentSize, layoutMeasurement }: NativeScrollEvent) {
|
|
36
|
+
return contentSize.height - layoutMeasurement.height - contentOffset.y <= AT_BOTTOM_THRESHOLD;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type ConversationContextValue = {
|
|
40
|
+
isAtBottom: boolean;
|
|
41
|
+
scrollToBottom: () => void;
|
|
42
|
+
scrollRef: RefObject<ScrollView | null>;
|
|
43
|
+
onScroll: (event: NativeScrollEvent) => void;
|
|
44
|
+
onUserScroll: () => void;
|
|
45
|
+
followContent: () => void;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const ConversationContext = createContext<ConversationContextValue | null>(null);
|
|
49
|
+
|
|
50
|
+
/** `isAtBottom` and `scrollToBottom` of the enclosing `Conversation`, for a custom scroll button. */
|
|
51
|
+
export function useConversation() {
|
|
52
|
+
const context = useContext(ConversationContext);
|
|
53
|
+
if (!context) throw new Error("Conversation parts must be used within Conversation");
|
|
54
|
+
return context;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export type ConversationProps = ViewProps & { className?: string };
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The thread frame (web `relative flex-1`). It sticks to the bottom: while the
|
|
61
|
+
* reader is at the end, new and growing messages stay in view; once they
|
|
62
|
+
* scroll up, it stops following and `ConversationScrollButton` shows.
|
|
63
|
+
*/
|
|
64
|
+
export function Conversation({ className, ...props }: ConversationProps) {
|
|
65
|
+
const reduceMotion = useReduceMotion();
|
|
66
|
+
const scrollRef = useRef<ScrollView | null>(null);
|
|
67
|
+
const atBottom = useRef(true);
|
|
68
|
+
// Set while our own animated scroll heads down, so its first frames (not
|
|
69
|
+
// yet at the bottom) do not count as the reader leaving.
|
|
70
|
+
const autoScrolling = useRef(false);
|
|
71
|
+
const [isAtBottom, setIsAtBottom] = useState(true);
|
|
72
|
+
|
|
73
|
+
const update = (next: boolean) => {
|
|
74
|
+
atBottom.current = next;
|
|
75
|
+
setIsAtBottom(next);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const value: ConversationContextValue = {
|
|
79
|
+
isAtBottom,
|
|
80
|
+
scrollRef,
|
|
81
|
+
scrollToBottom: () => {
|
|
82
|
+
autoScrolling.current = true;
|
|
83
|
+
update(true);
|
|
84
|
+
scrollRef.current?.scrollToEnd({ animated: !reduceMotion });
|
|
85
|
+
},
|
|
86
|
+
onScroll: (event) => {
|
|
87
|
+
const bottom = isScrolledToBottom(event);
|
|
88
|
+
if (bottom) autoScrolling.current = false;
|
|
89
|
+
else if (autoScrolling.current) return;
|
|
90
|
+
update(bottom);
|
|
91
|
+
},
|
|
92
|
+
onUserScroll: () => {
|
|
93
|
+
autoScrolling.current = false;
|
|
94
|
+
},
|
|
95
|
+
// A jump, not an animation: streamed text grows the content many times a second.
|
|
96
|
+
followContent: () => {
|
|
97
|
+
if (atBottom.current) scrollRef.current?.scrollToEnd({ animated: false });
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return (
|
|
102
|
+
<ConversationContext.Provider value={value}>
|
|
103
|
+
<View className={cn("relative flex-1", className)} {...props} />
|
|
104
|
+
</ConversationContext.Provider>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export type ConversationContentProps = ScrollViewProps & { className?: string };
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The scrolling message column: the web `flex flex-col gap-4 px-3 py-3`. A
|
|
112
|
+
* `ScrollView`; for very long threads an app can drive its own `FlatList`
|
|
113
|
+
* with `useConversation`.
|
|
114
|
+
*/
|
|
115
|
+
export function ConversationContent({
|
|
116
|
+
className,
|
|
117
|
+
contentContainerClassName,
|
|
118
|
+
onScroll,
|
|
119
|
+
onScrollBeginDrag,
|
|
120
|
+
onContentSizeChange,
|
|
121
|
+
onLayout,
|
|
122
|
+
...props
|
|
123
|
+
}: ConversationContentProps) {
|
|
124
|
+
const conversation = useConversation();
|
|
125
|
+
return (
|
|
126
|
+
<ScrollView
|
|
127
|
+
ref={conversation.scrollRef}
|
|
128
|
+
className={cn("flex-1", className)}
|
|
129
|
+
contentContainerClassName={cn("gap-4 px-3 py-3", contentContainerClassName)}
|
|
130
|
+
keyboardShouldPersistTaps="handled"
|
|
131
|
+
keyboardDismissMode={Platform.OS === "ios" ? "interactive" : "on-drag"}
|
|
132
|
+
scrollEventThrottle={16}
|
|
133
|
+
{...props}
|
|
134
|
+
onScroll={(event) => {
|
|
135
|
+
conversation.onScroll(event.nativeEvent);
|
|
136
|
+
onScroll?.(event);
|
|
137
|
+
}}
|
|
138
|
+
onScrollBeginDrag={(event) => {
|
|
139
|
+
conversation.onUserScroll();
|
|
140
|
+
onScrollBeginDrag?.(event);
|
|
141
|
+
}}
|
|
142
|
+
onContentSizeChange={(width, height) => {
|
|
143
|
+
conversation.followContent();
|
|
144
|
+
onContentSizeChange?.(width, height);
|
|
145
|
+
}}
|
|
146
|
+
// The keyboard or composer resizing the view keeps the end in view too.
|
|
147
|
+
onLayout={(event) => {
|
|
148
|
+
conversation.followContent();
|
|
149
|
+
onLayout?.(event);
|
|
150
|
+
}}
|
|
151
|
+
/>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type ConversationEmptyStateProps = ViewProps & {
|
|
156
|
+
title?: string;
|
|
157
|
+
description?: string;
|
|
158
|
+
icon?: ComponentType<IconProps>;
|
|
159
|
+
className?: string;
|
|
160
|
+
children?: ReactNode;
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
/** Centred placeholder for a thread with no messages: the web title (medium) and muted description. */
|
|
164
|
+
export function ConversationEmptyState({
|
|
165
|
+
title = "No messages yet",
|
|
166
|
+
description = "Start a conversation to see messages here",
|
|
167
|
+
icon: Icon,
|
|
168
|
+
className,
|
|
169
|
+
children,
|
|
170
|
+
...props
|
|
171
|
+
}: ConversationEmptyStateProps) {
|
|
172
|
+
const scheme = useScheme();
|
|
173
|
+
const largerText = useLargerText();
|
|
174
|
+
const size = chatTextSize("small", largerText);
|
|
175
|
+
return (
|
|
176
|
+
<View className={cn("flex-1 items-center justify-center gap-3 p-8", className)} {...props}>
|
|
177
|
+
{children ?? (
|
|
178
|
+
<>
|
|
179
|
+
{Icon ? <Icon size={largerText ? 28 : 24} color={tokens.color.muted[scheme]} /> : null}
|
|
180
|
+
<View className="items-center gap-1">
|
|
181
|
+
<Text
|
|
182
|
+
accessibilityRole="header"
|
|
183
|
+
className={cn(size, "text-center font-instrument-sans-medium")}
|
|
184
|
+
style={[sizeStyle(size), textStyle("foreground", scheme), typeStyle("medium")]}
|
|
185
|
+
>
|
|
186
|
+
{title}
|
|
187
|
+
</Text>
|
|
188
|
+
{description ? (
|
|
189
|
+
<Text
|
|
190
|
+
className={cn(size, "text-center font-instrument-sans")}
|
|
191
|
+
style={[sizeStyle(size), textStyle("muted", scheme)]}
|
|
192
|
+
>
|
|
193
|
+
{description}
|
|
194
|
+
</Text>
|
|
195
|
+
) : null}
|
|
196
|
+
</View>
|
|
197
|
+
</>
|
|
198
|
+
)}
|
|
199
|
+
</View>
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export type ConversationScrollButtonProps = {
|
|
204
|
+
/** The accessibility label. Default "Scroll to bottom". */
|
|
205
|
+
label?: string;
|
|
206
|
+
/** Replaces the down arrow. */
|
|
207
|
+
icon?: ComponentType<IconProps>;
|
|
208
|
+
testID?: string;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Round outline arrow, centred 16 above the bottom of the `Conversation`,
|
|
213
|
+
* shown while the reader is away from the end (the web `absolute bottom-4`).
|
|
214
|
+
* The web `Button` `icon` size (40, slop to 48). It has an opaque surface
|
|
215
|
+
* fill and no shadow, so it reads over the messages under it.
|
|
216
|
+
*/
|
|
217
|
+
export function ConversationScrollButton({
|
|
218
|
+
label = "Scroll to bottom",
|
|
219
|
+
icon: Icon = IconArrowDown,
|
|
220
|
+
testID,
|
|
221
|
+
}: ConversationScrollButtonProps) {
|
|
222
|
+
const { isAtBottom, scrollToBottom } = useConversation();
|
|
223
|
+
const scheme = useScheme();
|
|
224
|
+
const largerText = useLargerText();
|
|
225
|
+
if (isAtBottom) return null;
|
|
226
|
+
return (
|
|
227
|
+
<NativeOnlyAnimatedView
|
|
228
|
+
entering={FadeIn.duration(150)}
|
|
229
|
+
exiting={FadeOut.duration(150)}
|
|
230
|
+
pointerEvents="box-none"
|
|
231
|
+
style={{ position: "absolute", left: 0, right: 0, bottom: 16, alignItems: "center" }}
|
|
232
|
+
>
|
|
233
|
+
<Button
|
|
234
|
+
variant="outline"
|
|
235
|
+
size="icon"
|
|
236
|
+
accessibilityLabel={label}
|
|
237
|
+
onPress={scrollToBottom}
|
|
238
|
+
testID={testID}
|
|
239
|
+
style={{ borderRadius: tokens.radius.pill, backgroundColor: tokens.color.surface[scheme] }}
|
|
240
|
+
>
|
|
241
|
+
<Icon size={largerText ? 20 : 16} color={tokens.color.foreground[scheme]} />
|
|
242
|
+
</Button>
|
|
243
|
+
</NativeOnlyAnimatedView>
|
|
244
|
+
);
|
|
245
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Pressable, Text, View } from "react-native";
|
|
2
|
+
import { IconFile } from "@tabler/icons-react-native";
|
|
3
|
+
import { cn } from "../cn";
|
|
4
|
+
import { MIN_TOUCH_TARGET, sizeStyle, textStyle, touchSlop } from "../controlStyles";
|
|
5
|
+
import { palette } from "../palette";
|
|
6
|
+
import { useLargerText } from "../theme";
|
|
7
|
+
import { tokens } from "../tokens";
|
|
8
|
+
import { useScheme } from "../useScheme";
|
|
9
|
+
import { MONO_FONT, chatTextSize } from "./chat-text";
|
|
10
|
+
|
|
11
|
+
/** A `text-xs` line with the web `py-0.5` and border: 22 high. The slop brings it to 44. */
|
|
12
|
+
const CHIP_HEIGHT = 22;
|
|
13
|
+
|
|
14
|
+
function FileChipLabel({ name }: { name: string }) {
|
|
15
|
+
const scheme = useScheme();
|
|
16
|
+
const largerText = useLargerText();
|
|
17
|
+
const size = chatTextSize("caption", largerText);
|
|
18
|
+
return (
|
|
19
|
+
<View
|
|
20
|
+
className="max-w-full flex-row items-center gap-1.5 self-start border px-1.5 py-0.5"
|
|
21
|
+
style={{
|
|
22
|
+
borderRadius: tokens.radius.md,
|
|
23
|
+
borderColor: tokens.color.border[scheme],
|
|
24
|
+
backgroundColor: palette.default[scheme],
|
|
25
|
+
}}
|
|
26
|
+
>
|
|
27
|
+
<IconFile size={largerText ? 16 : 12} color={tokens.color.foreground[scheme]} />
|
|
28
|
+
<Text
|
|
29
|
+
numberOfLines={1}
|
|
30
|
+
ellipsizeMode="middle"
|
|
31
|
+
className={cn(size, "shrink")}
|
|
32
|
+
style={[sizeStyle(size), textStyle("foreground", scheme), { fontFamily: MONO_FONT }]}
|
|
33
|
+
>
|
|
34
|
+
{name}
|
|
35
|
+
</Text>
|
|
36
|
+
</View>
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type FileChipProps = {
|
|
41
|
+
name: string;
|
|
42
|
+
path?: string;
|
|
43
|
+
onOpenFile?: (path: string) => void;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A file name in a trace line or step body: the web `rounded-md border
|
|
48
|
+
* bg-secondary px-1.5 py-0.5 font-mono text-xs` chip. It is a button that
|
|
49
|
+
* opens the file when both `path` and `onOpenFile` are given, and plain text
|
|
50
|
+
* otherwise. Long names are cut in the middle, so the extension shows.
|
|
51
|
+
*/
|
|
52
|
+
export function FileChip({ name, path, onOpenFile }: FileChipProps) {
|
|
53
|
+
if (!path || !onOpenFile) return <FileChipLabel name={name} />;
|
|
54
|
+
return (
|
|
55
|
+
<Pressable
|
|
56
|
+
accessibilityRole="button"
|
|
57
|
+
accessibilityLabel={name}
|
|
58
|
+
accessibilityHint={path}
|
|
59
|
+
hitSlop={touchSlop(MIN_TOUCH_TARGET, CHIP_HEIGHT)}
|
|
60
|
+
onPress={() => onOpenFile(path)}
|
|
61
|
+
className="min-w-0 max-w-full active:opacity-70"
|
|
62
|
+
>
|
|
63
|
+
<FileChipLabel name={name} />
|
|
64
|
+
</Pressable>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { ComponentType } from "react";
|
|
2
|
+
import type { IconProps } from "@tabler/icons-react-native";
|
|
3
|
+
import { Button, type ButtonProps } from "../Button";
|
|
4
|
+
import { cn } from "../cn";
|
|
5
|
+
import { MIN_TOUCH_TARGET, buttonColors, touchSlop } from "../controlStyles";
|
|
6
|
+
import { useLargerText } from "../theme";
|
|
7
|
+
import { tokens } from "../tokens";
|
|
8
|
+
import { useScheme } from "../useScheme";
|
|
9
|
+
|
|
10
|
+
/** The web `icon-sm` button: 32 square with a 16 icon. */
|
|
11
|
+
export const CHAT_ICON_BUTTON_SIZE = 32;
|
|
12
|
+
|
|
13
|
+
export type ChatIconButtonProps = Omit<ButtonProps, "children" | "size" | "icon"> & {
|
|
14
|
+
icon: ComponentType<IconProps>;
|
|
15
|
+
/** Names the button for VoiceOver and TalkBack; it has no text. */
|
|
16
|
+
label: string;
|
|
17
|
+
/** Default: the variant's label colour. */
|
|
18
|
+
iconColor?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A round `Button` at the web `icon-sm` size (32), with the slop to 44. With
|
|
23
|
+
* larger text it is painted 44 with a 20 icon. The message
|
|
24
|
+
* actions, the composer tools and its send button, and the code copy button.
|
|
25
|
+
*/
|
|
26
|
+
export function ChatIconButton({
|
|
27
|
+
icon: Icon,
|
|
28
|
+
label,
|
|
29
|
+
iconColor,
|
|
30
|
+
variant = "ghost",
|
|
31
|
+
className,
|
|
32
|
+
...props
|
|
33
|
+
}: ChatIconButtonProps) {
|
|
34
|
+
const scheme = useScheme();
|
|
35
|
+
const largerText = useLargerText();
|
|
36
|
+
const size = largerText ? MIN_TOUCH_TARGET : CHAT_ICON_BUTTON_SIZE;
|
|
37
|
+
const color = iconColor ?? buttonColors(variant, scheme)?.color ?? tokens.color.foreground[scheme];
|
|
38
|
+
return (
|
|
39
|
+
<Button
|
|
40
|
+
variant={variant}
|
|
41
|
+
size="icon"
|
|
42
|
+
accessibilityLabel={label}
|
|
43
|
+
hitSlop={touchSlop(size)}
|
|
44
|
+
className={cn(largerText ? "h-11 w-11" : "h-8 w-8", className)}
|
|
45
|
+
style={{ borderRadius: size / 2 }}
|
|
46
|
+
{...props}
|
|
47
|
+
>
|
|
48
|
+
<Icon size={largerText ? 20 : 16} color={color} />
|
|
49
|
+
</Button>
|
|
50
|
+
);
|
|
51
|
+
}
|
package/src/ai/index.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
// AI chat parts, ported from the `@vvedantb/ui` AI elements for touch. On the
|
|
2
|
+
// `@vvedantb/native-ui/ai` entry only, so apps without a chat do not bundle
|
|
3
|
+
// them. Generic: no model names and no app logic.
|
|
4
|
+
export { useStreamingDisclosure } from "./use-streaming-disclosure";
|
|
5
|
+
export { chatTextSize, formatElapsed, MONO_FONT } from "./chat-text";
|
|
6
|
+
export type { ChatTextKind } from "./chat-text";
|
|
7
|
+
export { Shimmer, SHIMMER_LOW_OPACITY } from "./shimmer";
|
|
8
|
+
export type { ShimmerProps } from "./shimmer";
|
|
9
|
+
export {
|
|
10
|
+
Collapsible,
|
|
11
|
+
CollapsibleTrigger,
|
|
12
|
+
CollapsibleContent,
|
|
13
|
+
CollapsibleChevron,
|
|
14
|
+
useCollapsible,
|
|
15
|
+
} from "./collapsible";
|
|
16
|
+
export type { CollapsibleProps, CollapsibleTriggerProps } from "./collapsible";
|
|
17
|
+
export {
|
|
18
|
+
Conversation,
|
|
19
|
+
ConversationContent,
|
|
20
|
+
ConversationEmptyState,
|
|
21
|
+
ConversationScrollButton,
|
|
22
|
+
useConversation,
|
|
23
|
+
isScrolledToBottom,
|
|
24
|
+
AT_BOTTOM_THRESHOLD,
|
|
25
|
+
} from "./conversation";
|
|
26
|
+
export type {
|
|
27
|
+
ConversationProps,
|
|
28
|
+
ConversationContentProps,
|
|
29
|
+
ConversationEmptyStateProps,
|
|
30
|
+
ConversationScrollButtonProps,
|
|
31
|
+
} from "./conversation";
|
|
32
|
+
export {
|
|
33
|
+
Message,
|
|
34
|
+
MessageContent,
|
|
35
|
+
MessageActionsBar,
|
|
36
|
+
MessageAction,
|
|
37
|
+
MESSAGE_ACTIONS,
|
|
38
|
+
userBubbleStyle,
|
|
39
|
+
} from "./message";
|
|
40
|
+
export type {
|
|
41
|
+
MessageRole,
|
|
42
|
+
MessageProps,
|
|
43
|
+
MessageContentProps,
|
|
44
|
+
MessageActionsBarProps,
|
|
45
|
+
MessageActionKind,
|
|
46
|
+
MessageActionProps,
|
|
47
|
+
} from "./message";
|
|
48
|
+
export {
|
|
49
|
+
Reasoning,
|
|
50
|
+
ReasoningTrigger,
|
|
51
|
+
ReasoningContent,
|
|
52
|
+
useReasoning,
|
|
53
|
+
reasoningMessage,
|
|
54
|
+
} from "./reasoning";
|
|
55
|
+
export type { ReasoningProps, ReasoningTriggerProps, ReasoningContentProps } from "./reasoning";
|
|
56
|
+
export {
|
|
57
|
+
AgentTrace,
|
|
58
|
+
AgentTraceLine,
|
|
59
|
+
AgentTraceIcon,
|
|
60
|
+
AgentTraceTitle,
|
|
61
|
+
AgentTraceDescription,
|
|
62
|
+
AgentTraceCollapsible,
|
|
63
|
+
AgentTraceCollapsibleTrigger,
|
|
64
|
+
AgentTraceCollapsibleContent,
|
|
65
|
+
traceLineColor,
|
|
66
|
+
} from "./trace";
|
|
67
|
+
export type {
|
|
68
|
+
AgentTraceStatus,
|
|
69
|
+
AgentTraceLineProps,
|
|
70
|
+
AgentTraceIconProps,
|
|
71
|
+
AgentTraceTitleProps,
|
|
72
|
+
AgentTraceCollapsibleTriggerProps,
|
|
73
|
+
} from "./trace";
|
|
74
|
+
export {
|
|
75
|
+
ChainOfThought,
|
|
76
|
+
ChainOfThoughtHeader,
|
|
77
|
+
ChainOfThoughtContent,
|
|
78
|
+
ChainOfThoughtStep,
|
|
79
|
+
ChainOfThoughtSearchResults,
|
|
80
|
+
ChainOfThoughtSearchResult,
|
|
81
|
+
} from "./chain-of-thought";
|
|
82
|
+
export type {
|
|
83
|
+
ChainOfThoughtHeaderProps,
|
|
84
|
+
ChainOfThoughtStepProps,
|
|
85
|
+
ChainOfThoughtStepStatus,
|
|
86
|
+
} from "./chain-of-thought";
|
|
87
|
+
export { ActivityStep } from "./activity-step";
|
|
88
|
+
export type { ActivityStepProps, ActivityStepStatus } from "./activity-step";
|
|
89
|
+
export {
|
|
90
|
+
PromptInput,
|
|
91
|
+
PromptInputButton,
|
|
92
|
+
PromptInputSubmit,
|
|
93
|
+
isGenerating,
|
|
94
|
+
} from "./prompt-input";
|
|
95
|
+
export type {
|
|
96
|
+
ChatStatus,
|
|
97
|
+
PromptInputProps,
|
|
98
|
+
PromptInputButtonProps,
|
|
99
|
+
PromptInputSubmitProps,
|
|
100
|
+
} from "./prompt-input";
|
|
101
|
+
export { Suggestions, Suggestion } from "./suggestion";
|
|
102
|
+
export type { SuggestionsProps, SuggestionProps } from "./suggestion";
|
|
103
|
+
export { Sources, SourcesTrigger, SourcesContent, Source, sourcesLabel } from "./sources";
|
|
104
|
+
export type { SourcesTriggerProps, SourceProps } from "./sources";
|
|
105
|
+
export { FileChip } from "./file-chip";
|
|
106
|
+
export type { FileChipProps } from "./file-chip";
|
|
107
|
+
export {
|
|
108
|
+
Attachments,
|
|
109
|
+
Attachment,
|
|
110
|
+
getMediaCategory,
|
|
111
|
+
getAttachmentLabel,
|
|
112
|
+
} from "./attachments";
|
|
113
|
+
export type {
|
|
114
|
+
AttachmentData,
|
|
115
|
+
AttachmentMediaCategory,
|
|
116
|
+
AttachmentVariant,
|
|
117
|
+
AttachmentsProps,
|
|
118
|
+
AttachmentProps,
|
|
119
|
+
} from "./attachments";
|
|
120
|
+
export { CodeBlock, CodeBlockCopyButton } from "./code-block";
|
|
121
|
+
export type { CodeBlockProps, CodeBlockCopyButtonProps } from "./code-block";
|
|
122
|
+
export { ResponseText, ChatTextColorContext } from "./response-text";
|
|
123
|
+
export type { ResponseTextProps } from "./response-text";
|