@vvedantb/native-ui 0.12.0 → 0.13.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 +47 -4
- package/package.json +1 -1
- package/src/DropdownMenu.tsx +35 -30
- package/src/FloatingSurface.tsx +2 -1
- package/src/ai/card-button.tsx +49 -0
- package/src/ai/index.ts +14 -0
- package/src/ai/message-action-sheet.tsx +165 -0
- package/src/ai/message-quote.tsx +98 -0
- package/src/ai/message.tsx +180 -14
- package/src/ai/prompt-input.tsx +25 -5
- package/src/ai/reply-quote-chip.tsx +90 -0
- package/src/ai/subagent-cards.tsx +209 -0
- package/src/ai/tool-approval-card.tsx +162 -0
- package/src/definiteSize.ts +12 -0
package/src/ai/message.tsx
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { createContext, useContext, type ComponentType, type ReactNode } from "react";
|
|
2
|
-
import { Text, View, type ViewProps } from "react-native";
|
|
1
|
+
import { createContext, useContext, useEffect, useState, type ComponentType, type ReactNode } from "react";
|
|
2
|
+
import { Pressable, StyleSheet, Text, View, type ViewProps } from "react-native";
|
|
3
|
+
import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated";
|
|
3
4
|
import {
|
|
5
|
+
IconArrowBackUp,
|
|
4
6
|
IconCopy,
|
|
5
7
|
IconRefresh,
|
|
6
8
|
IconThumbDown,
|
|
@@ -8,13 +10,15 @@ import {
|
|
|
8
10
|
type IconProps,
|
|
9
11
|
} from "@tabler/icons-react-native";
|
|
10
12
|
import { cn } from "../cn";
|
|
11
|
-
import { sizeStyle } from "../controlStyles";
|
|
13
|
+
import { sizeStyle, typeStyle } from "../controlStyles";
|
|
12
14
|
import { palette, withAlpha } from "../palette";
|
|
13
15
|
import { useLargerText } from "../theme";
|
|
14
16
|
import { tokens } from "../tokens";
|
|
17
|
+
import { useReduceMotion } from "../useReduceMotion";
|
|
15
18
|
import { useScheme } from "../useScheme";
|
|
16
19
|
import { chatTextSize } from "./chat-text";
|
|
17
20
|
import { ChatIconButton } from "./icon-button";
|
|
21
|
+
import { MessageActionSheet, type MessageSheetAction } from "./message-action-sheet";
|
|
18
22
|
import { ChatTextColorContext, ResponseText } from "./response-text";
|
|
19
23
|
|
|
20
24
|
export type MessageRole = "user" | "assistant" | "system";
|
|
@@ -24,25 +28,140 @@ const MessageContext = createContext<MessageRole>("assistant");
|
|
|
24
28
|
export type MessageProps = ViewProps & {
|
|
25
29
|
from: MessageRole;
|
|
26
30
|
className?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Called on a long press of the message. Without it (and without `actions`)
|
|
33
|
+
* the message is a plain `View`, as before.
|
|
34
|
+
*/
|
|
35
|
+
onLongPress?: () => void;
|
|
36
|
+
/**
|
|
37
|
+
* Actions for a `MessageActionSheet` that opens on a long press, such as
|
|
38
|
+
* Reply and Copy. `onLongPress` also runs, before the sheet opens.
|
|
39
|
+
*/
|
|
40
|
+
actions?: MessageSheetAction[];
|
|
41
|
+
/** Title of the action sheet. Default "Message actions". */
|
|
42
|
+
actionSheetTitle?: string;
|
|
43
|
+
/**
|
|
44
|
+
* True while the message is the target of a quote tap: a primary wash that
|
|
45
|
+
* fades over about 1.4 seconds, and a ring while it stays true. Set it back
|
|
46
|
+
* to `false` after the scroll, about 1.5 seconds later.
|
|
47
|
+
*/
|
|
48
|
+
highlighted?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* For group chats: which bot sent this message. Draws a `MessageBotLabel`
|
|
51
|
+
* (avatar and name) above the content. Only when set; assistant messages only.
|
|
52
|
+
*/
|
|
53
|
+
bot?: MessageBot;
|
|
27
54
|
};
|
|
28
55
|
|
|
56
|
+
/** The bot behind an incoming message in a group chat. */
|
|
57
|
+
export type MessageBot = { name: string; avatar?: ReactNode };
|
|
58
|
+
|
|
59
|
+
/** How long the highlight wash takes to fade, in milliseconds. */
|
|
60
|
+
export const MESSAGE_HIGHLIGHT_MS = 1400;
|
|
61
|
+
|
|
62
|
+
/** Peak opacity of the highlight wash (the primary colour). */
|
|
63
|
+
const HIGHLIGHT_WASH = 0.18;
|
|
64
|
+
|
|
65
|
+
function MessageHighlight() {
|
|
66
|
+
const scheme = useScheme();
|
|
67
|
+
const reduceMotion = useReduceMotion();
|
|
68
|
+
const wash = useSharedValue(reduceMotion ? 0.5 : 1);
|
|
69
|
+
useEffect(() => {
|
|
70
|
+
if (reduceMotion) {
|
|
71
|
+
wash.value = 0.5;
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
wash.value = 1;
|
|
75
|
+
wash.value = withTiming(0, { duration: MESSAGE_HIGHLIGHT_MS });
|
|
76
|
+
}, [wash, reduceMotion]);
|
|
77
|
+
const animatedStyle = useAnimatedStyle(() => ({ opacity: wash.value }));
|
|
78
|
+
return (
|
|
79
|
+
<Animated.View
|
|
80
|
+
testID="message-highlight"
|
|
81
|
+
pointerEvents="none"
|
|
82
|
+
style={[
|
|
83
|
+
StyleSheet.absoluteFill,
|
|
84
|
+
{
|
|
85
|
+
borderRadius: tokens.radius.lg,
|
|
86
|
+
borderWidth: 1.5,
|
|
87
|
+
borderColor: withAlpha(palette.primary[scheme], 0.5),
|
|
88
|
+
backgroundColor: withAlpha(palette.primary[scheme], HIGHLIGHT_WASH),
|
|
89
|
+
},
|
|
90
|
+
animatedStyle,
|
|
91
|
+
]}
|
|
92
|
+
/>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
29
96
|
/**
|
|
30
97
|
* One message: the web `w-full max-w-[95%] flex-col gap-2`. A user message
|
|
31
98
|
* sits at the end of the row with its parts at the end; an assistant message
|
|
32
99
|
* at the start, its parts full width.
|
|
100
|
+
*
|
|
101
|
+
* With `onLongPress` or `actions` the message is a `Pressable` (same layout,
|
|
102
|
+
* `className`, `style` and view props) and a long press opens the
|
|
103
|
+
* `MessageActionSheet`. It stays a non-accessible container so its buttons and
|
|
104
|
+
* links keep their own focus: also offer the actions in a `MessageActionsBar`
|
|
105
|
+
* for screen reader users. `highlighted` draws the quote-jump highlight.
|
|
33
106
|
*/
|
|
34
|
-
export function Message({
|
|
107
|
+
export function Message({
|
|
108
|
+
from,
|
|
109
|
+
className,
|
|
110
|
+
style,
|
|
111
|
+
onLongPress,
|
|
112
|
+
actions,
|
|
113
|
+
actionSheetTitle,
|
|
114
|
+
highlighted = false,
|
|
115
|
+
bot,
|
|
116
|
+
children,
|
|
117
|
+
...props
|
|
118
|
+
}: MessageProps) {
|
|
35
119
|
const user = from === "user";
|
|
120
|
+
const [sheetOpen, setSheetOpen] = useState(false);
|
|
121
|
+
const hasActions = actions != null && actions.length > 0;
|
|
122
|
+
const longPressable = onLongPress != null || hasActions;
|
|
123
|
+
const layout = [
|
|
124
|
+
{ alignSelf: user ? "flex-end" : "flex-start", alignItems: user ? "flex-end" : "stretch" },
|
|
125
|
+
style,
|
|
126
|
+
] as const;
|
|
127
|
+
const merged = cn("w-full max-w-[95%] gap-2", className);
|
|
128
|
+
const parts = (
|
|
129
|
+
<>
|
|
130
|
+
{bot && !user ? <MessageBotLabel name={bot.name} avatar={bot.avatar} /> : null}
|
|
131
|
+
{children}
|
|
132
|
+
{highlighted ? <MessageHighlight /> : null}
|
|
133
|
+
</>
|
|
134
|
+
);
|
|
36
135
|
return (
|
|
37
136
|
<MessageContext.Provider value={from}>
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
137
|
+
{longPressable ? (
|
|
138
|
+
<>
|
|
139
|
+
<Pressable
|
|
140
|
+
accessible={false}
|
|
141
|
+
className={merged}
|
|
142
|
+
style={layout as never}
|
|
143
|
+
onLongPress={() => {
|
|
144
|
+
onLongPress?.();
|
|
145
|
+
if (hasActions) setSheetOpen(true);
|
|
146
|
+
}}
|
|
147
|
+
{...props}
|
|
148
|
+
>
|
|
149
|
+
{parts}
|
|
150
|
+
</Pressable>
|
|
151
|
+
{hasActions ? (
|
|
152
|
+
<MessageActionSheet
|
|
153
|
+
open={sheetOpen}
|
|
154
|
+
onClose={() => setSheetOpen(false)}
|
|
155
|
+
actions={actions}
|
|
156
|
+
title={actionSheetTitle}
|
|
157
|
+
/>
|
|
158
|
+
) : null}
|
|
159
|
+
</>
|
|
160
|
+
) : (
|
|
161
|
+
<View className={merged} style={layout} {...props}>
|
|
162
|
+
{parts}
|
|
163
|
+
</View>
|
|
164
|
+
)}
|
|
46
165
|
</MessageContext.Provider>
|
|
47
166
|
);
|
|
48
167
|
}
|
|
@@ -75,9 +194,47 @@ export function tintBubbleStyle(scheme: "light" | "dark") {
|
|
|
75
194
|
};
|
|
76
195
|
}
|
|
77
196
|
|
|
197
|
+
export type MessageBotLabelProps = {
|
|
198
|
+
name: string;
|
|
199
|
+
/** An avatar node, such as `<Avatar size="xs" />`. Drawn before the name. */
|
|
200
|
+
avatar?: ReactNode;
|
|
201
|
+
className?: string;
|
|
202
|
+
testID?: string;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Which bot sent a message, for group chats: an optional avatar and the name
|
|
207
|
+
* in the caption size. `Message` draws it for its `bot` prop. To compose it
|
|
208
|
+
* yourself, render it above `MessageContent` (for example in `renderMessage`).
|
|
209
|
+
*/
|
|
210
|
+
export function MessageBotLabel({ name, avatar, className, testID = "message-bot-label" }: MessageBotLabelProps) {
|
|
211
|
+
const scheme = useScheme();
|
|
212
|
+
const largerText = useLargerText();
|
|
213
|
+
const size = chatTextSize("caption", largerText);
|
|
214
|
+
return (
|
|
215
|
+
<View
|
|
216
|
+
testID={testID}
|
|
217
|
+
accessible
|
|
218
|
+
accessibilityLabel={`From ${name}`}
|
|
219
|
+
className={cn("flex-row items-center gap-1.5", className)}
|
|
220
|
+
>
|
|
221
|
+
{avatar}
|
|
222
|
+
<Text
|
|
223
|
+
numberOfLines={1}
|
|
224
|
+
className={cn(size, "font-instrument-sans-semibold")}
|
|
225
|
+
style={[sizeStyle(size), typeStyle("semibold"), { color: tokens.color.muted[scheme] }]}
|
|
226
|
+
>
|
|
227
|
+
{name}
|
|
228
|
+
</Text>
|
|
229
|
+
</View>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
78
233
|
export type MessageContentProps = ViewProps & {
|
|
79
234
|
/** User messages only. Default `"primary"`. */
|
|
80
235
|
bubble?: MessageBubble;
|
|
236
|
+
/** A `MessageQuote` drawn above the content, inside the bubble. */
|
|
237
|
+
quote?: ReactNode;
|
|
81
238
|
className?: string;
|
|
82
239
|
};
|
|
83
240
|
|
|
@@ -88,13 +245,14 @@ export type MessageContentProps = ViewProps & {
|
|
|
88
245
|
* no fill and the foreground colour. A string child is drawn as
|
|
89
246
|
* `ResponseText`, and `ResponseText` inside takes the colour.
|
|
90
247
|
*/
|
|
91
|
-
export function MessageContent({ children, bubble = "primary", className, style, ...props }: MessageContentProps) {
|
|
248
|
+
export function MessageContent({ children, bubble = "primary", quote, className, style, ...props }: MessageContentProps) {
|
|
92
249
|
const from = useContext(MessageContext);
|
|
93
250
|
const scheme = useScheme();
|
|
94
251
|
const user = from === "user";
|
|
95
252
|
const tint = user && bubble === "tint";
|
|
96
253
|
const color = user && !tint ? palette.primaryForeground[scheme] : tokens.color.foreground[scheme];
|
|
97
254
|
const bubbleStyle = !user ? null : tint ? tintBubbleStyle(scheme) : userBubbleStyle(scheme);
|
|
255
|
+
const body = typeof children === "string" ? <ResponseText>{children}</ResponseText> : children;
|
|
98
256
|
return (
|
|
99
257
|
<ChatTextColorContext.Provider value={color}>
|
|
100
258
|
<View
|
|
@@ -102,7 +260,14 @@ export function MessageContent({ children, bubble = "primary", className, style,
|
|
|
102
260
|
style={[bubbleStyle, style]}
|
|
103
261
|
{...props}
|
|
104
262
|
>
|
|
105
|
-
{
|
|
263
|
+
{quote ? (
|
|
264
|
+
<>
|
|
265
|
+
{quote}
|
|
266
|
+
{body}
|
|
267
|
+
</>
|
|
268
|
+
) : (
|
|
269
|
+
body
|
|
270
|
+
)}
|
|
106
271
|
</View>
|
|
107
272
|
</ChatTextColorContext.Provider>
|
|
108
273
|
);
|
|
@@ -138,6 +303,7 @@ export function MessageActionsBar({ className, ...props }: MessageActionsBarProp
|
|
|
138
303
|
|
|
139
304
|
/** The usual actions, with their icons and accessibility labels. */
|
|
140
305
|
export const MESSAGE_ACTIONS = {
|
|
306
|
+
reply: { icon: IconArrowBackUp, label: "Reply" },
|
|
141
307
|
copy: { icon: IconCopy, label: "Copy" },
|
|
142
308
|
retry: { icon: IconRefresh, label: "Retry" },
|
|
143
309
|
like: { icon: IconThumbUp, label: "Good response" },
|
package/src/ai/prompt-input.tsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
2
2
|
import { KeyboardAvoidingView, View } from "react-native";
|
|
3
3
|
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
4
4
|
import { IconArrowUp, IconPlayerStopFilled } from "@tabler/icons-react-native";
|
|
@@ -89,6 +89,12 @@ export type PromptInputProps = {
|
|
|
89
89
|
placeholder?: string;
|
|
90
90
|
/** Names the field for VoiceOver and TalkBack. Default "Message". */
|
|
91
91
|
accessibilityLabel?: string;
|
|
92
|
+
/**
|
|
93
|
+
* A pending reply above the field and above `header`: a `ReplyQuoteChip`.
|
|
94
|
+
* Clear it in the app with the chip's `onDismiss`. A `ReplyQuoteChip` as
|
|
95
|
+
* `header` works as well, beside any attachments.
|
|
96
|
+
*/
|
|
97
|
+
replyTo?: ReactNode;
|
|
92
98
|
/** Above the field, such as `Attachments`. */
|
|
93
99
|
header?: ReactNode;
|
|
94
100
|
/** Before the send button, such as `PromptInputButton`s. */
|
|
@@ -134,6 +140,7 @@ export function PromptInput({
|
|
|
134
140
|
onStop,
|
|
135
141
|
placeholder = "What would you like to know?",
|
|
136
142
|
accessibilityLabel = "Message",
|
|
143
|
+
replyTo,
|
|
137
144
|
header,
|
|
138
145
|
toolbar,
|
|
139
146
|
layout = "stacked",
|
|
@@ -151,11 +158,18 @@ export function PromptInput({
|
|
|
151
158
|
const message = text.trim();
|
|
152
159
|
const canSubmit = message.length > 0 && !disabled && !isGenerating(status);
|
|
153
160
|
const bottom = safeBottom ? insets.bottom : 0;
|
|
161
|
+
// The field grows with its content up to `maxHeight`, then scrolls. A fixed
|
|
162
|
+
// `numberOfLines` would lock the line count on Android, so neither layout sets it.
|
|
163
|
+
const [contentHeight, setContentHeight] = useState(0);
|
|
164
|
+
const maxHeight = largerText ? MAX_FIELD_HEIGHT.larger : MAX_FIELD_HEIGHT.regular;
|
|
165
|
+
const growStyle = contentHeight > 0 ? { height: Math.min(contentHeight, maxHeight) } : null;
|
|
166
|
+
const scrollEnabled = contentHeight > maxHeight;
|
|
154
167
|
|
|
155
168
|
const submit = () => {
|
|
156
169
|
if (!canSubmit) return;
|
|
157
170
|
onSubmit(message);
|
|
158
171
|
setText("");
|
|
172
|
+
setContentHeight(0);
|
|
159
173
|
};
|
|
160
174
|
|
|
161
175
|
return (
|
|
@@ -173,6 +187,7 @@ export function PromptInput({
|
|
|
173
187
|
{layout === "inline" ? (
|
|
174
188
|
// Web `Composer` inline: `rounded-[1.25rem]` and a `p-1.5` row, items at the bottom as the field grows.
|
|
175
189
|
<View className="border" style={[fieldStyle(scheme), { borderRadius: INLINE_RADIUS }]}>
|
|
190
|
+
{replyTo ? <View className="px-1.5 pt-1.5">{replyTo}</View> : null}
|
|
176
191
|
{header ? <View className="flex-row flex-wrap gap-1 px-1.5 pt-1.5">{header}</View> : null}
|
|
177
192
|
<View className="flex-row items-end gap-1 p-1.5">
|
|
178
193
|
{leading ? <View className="shrink-0 flex-row items-center gap-1">{leading}</View> : null}
|
|
@@ -184,8 +199,8 @@ export function PromptInput({
|
|
|
184
199
|
accessibilityLabel={accessibilityLabel}
|
|
185
200
|
editable={!disabled}
|
|
186
201
|
textAlignVertical="center"
|
|
187
|
-
|
|
188
|
-
|
|
202
|
+
scrollEnabled={scrollEnabled}
|
|
203
|
+
onContentSizeChange={(e) => setContentHeight(e.nativeEvent.contentSize.height)}
|
|
189
204
|
className={cn(
|
|
190
205
|
"min-h-8 flex-1 border-0 px-2 py-1 text-base font-instrument-sans",
|
|
191
206
|
largerText && "min-h-11 py-2 text-xl font-instrument-sans-medium",
|
|
@@ -195,7 +210,8 @@ export function PromptInput({
|
|
|
195
210
|
largerText ? typeStyle("medium") : null,
|
|
196
211
|
{
|
|
197
212
|
backgroundColor: "transparent",
|
|
198
|
-
maxHeight
|
|
213
|
+
maxHeight,
|
|
214
|
+
...growStyle,
|
|
199
215
|
},
|
|
200
216
|
]}
|
|
201
217
|
/>
|
|
@@ -207,6 +223,7 @@ export function PromptInput({
|
|
|
207
223
|
</View>
|
|
208
224
|
) : (
|
|
209
225
|
<View className="border" style={fieldStyle(scheme)}>
|
|
226
|
+
{replyTo ? <View className="px-2 pt-2">{replyTo}</View> : null}
|
|
210
227
|
{header ? <View className="flex-row flex-wrap gap-1 px-2 pt-2">{header}</View> : null}
|
|
211
228
|
{/* The `Textarea` field without its `numberOfLines` (default 4), which on Android sets the line count: this one starts at one line and grows. */}
|
|
212
229
|
<FieldInput
|
|
@@ -217,6 +234,8 @@ export function PromptInput({
|
|
|
217
234
|
accessibilityLabel={accessibilityLabel}
|
|
218
235
|
editable={!disabled}
|
|
219
236
|
textAlignVertical="top"
|
|
237
|
+
scrollEnabled={scrollEnabled}
|
|
238
|
+
onContentSizeChange={(e) => setContentHeight(e.nativeEvent.contentSize.height)}
|
|
220
239
|
className={cn(
|
|
221
240
|
"min-h-11 border-0 px-3.5 py-2.5 text-base font-instrument-sans",
|
|
222
241
|
largerText && "min-h-14 px-4 py-3 text-xl font-instrument-sans-medium",
|
|
@@ -226,7 +245,8 @@ export function PromptInput({
|
|
|
226
245
|
largerText ? typeStyle("medium") : null,
|
|
227
246
|
{
|
|
228
247
|
backgroundColor: "transparent",
|
|
229
|
-
maxHeight
|
|
248
|
+
maxHeight,
|
|
249
|
+
...growStyle,
|
|
230
250
|
},
|
|
231
251
|
]}
|
|
232
252
|
/>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Pressable, Text, View } from "react-native";
|
|
2
|
+
import { IconX } from "@tabler/icons-react-native";
|
|
3
|
+
import { cn } from "../cn";
|
|
4
|
+
import { MIN_TOUCH_TARGET, fieldStyle, sizeStyle, textStyle, touchSlop, typeStyle } from "../controlStyles";
|
|
5
|
+
import { palette } from "../palette";
|
|
6
|
+
import { useLargerText } from "../theme";
|
|
7
|
+
import { tokens } from "../tokens";
|
|
8
|
+
import { useScheme } from "../useScheme";
|
|
9
|
+
import { chatTextSize } from "./chat-text";
|
|
10
|
+
|
|
11
|
+
export type ReplyQuoteChipProps = {
|
|
12
|
+
/** Who wrote the message being replied to. */
|
|
13
|
+
author: string;
|
|
14
|
+
/** Its text. Shown on up to two lines. */
|
|
15
|
+
text: string;
|
|
16
|
+
/** Called when the X is pressed. Clear the reply in the app. */
|
|
17
|
+
onDismiss?: () => void;
|
|
18
|
+
/** Default "Replying to {author}". */
|
|
19
|
+
accessibilityLabel?: string;
|
|
20
|
+
/** Names the X button. Default "Dismiss reply quote". */
|
|
21
|
+
dismissLabel?: string;
|
|
22
|
+
className?: string;
|
|
23
|
+
testID?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The chip above the composer while a reply is pending: a field-style
|
|
28
|
+
* surface with a primary accent bar, the author, up to two lines of the
|
|
29
|
+
* message and an X (44 point target). Pass it as `PromptInput` `replyTo`, or
|
|
30
|
+
* as `header`.
|
|
31
|
+
*/
|
|
32
|
+
export function ReplyQuoteChip({
|
|
33
|
+
author,
|
|
34
|
+
text,
|
|
35
|
+
onDismiss,
|
|
36
|
+
accessibilityLabel,
|
|
37
|
+
dismissLabel = "Dismiss reply quote",
|
|
38
|
+
className,
|
|
39
|
+
testID,
|
|
40
|
+
}: ReplyQuoteChipProps) {
|
|
41
|
+
const scheme = useScheme();
|
|
42
|
+
const largerText = useLargerText();
|
|
43
|
+
const caption = chatTextSize("caption", largerText);
|
|
44
|
+
const body = chatTextSize("small", largerText);
|
|
45
|
+
return (
|
|
46
|
+
<View
|
|
47
|
+
testID={testID}
|
|
48
|
+
accessible
|
|
49
|
+
accessibilityLabel={accessibilityLabel ?? `Replying to ${author}`}
|
|
50
|
+
accessibilityHint={text}
|
|
51
|
+
className={cn("w-full flex-row items-center rounded-field border pl-3", className)}
|
|
52
|
+
style={[
|
|
53
|
+
fieldStyle(scheme),
|
|
54
|
+
{ backgroundColor: tokens.color.surfaceSecondary[scheme] },
|
|
55
|
+
]}
|
|
56
|
+
>
|
|
57
|
+
<View
|
|
58
|
+
className="mr-2.5 self-stretch rounded-full"
|
|
59
|
+
style={{ width: 3, marginVertical: 8, backgroundColor: palette.primary[scheme] }}
|
|
60
|
+
/>
|
|
61
|
+
<View className="min-w-0 flex-1 py-2">
|
|
62
|
+
<Text
|
|
63
|
+
numberOfLines={1}
|
|
64
|
+
className={cn(caption, "font-instrument-sans-semibold")}
|
|
65
|
+
style={[sizeStyle(caption), typeStyle("semibold"), textStyle("muted", scheme)]}
|
|
66
|
+
>
|
|
67
|
+
{author}
|
|
68
|
+
</Text>
|
|
69
|
+
<Text
|
|
70
|
+
numberOfLines={2}
|
|
71
|
+
className={cn(body, "font-instrument-sans")}
|
|
72
|
+
style={[sizeStyle(body), textStyle("foreground", scheme)]}
|
|
73
|
+
>
|
|
74
|
+
{text}
|
|
75
|
+
</Text>
|
|
76
|
+
</View>
|
|
77
|
+
<Pressable
|
|
78
|
+
testID={testID ? `${testID}-dismiss` : undefined}
|
|
79
|
+
accessibilityRole="button"
|
|
80
|
+
accessibilityLabel={dismissLabel}
|
|
81
|
+
onPress={onDismiss}
|
|
82
|
+
hitSlop={touchSlop(MIN_TOUCH_TARGET)}
|
|
83
|
+
className="items-center justify-center"
|
|
84
|
+
style={{ width: MIN_TOUCH_TARGET, height: MIN_TOUCH_TARGET }}
|
|
85
|
+
>
|
|
86
|
+
<IconX size={largerText ? 20 : 16} color={tokens.color.muted[scheme]} />
|
|
87
|
+
</Pressable>
|
|
88
|
+
</View>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import { Text, View } from "react-native";
|
|
2
|
+
import { IconRobot } from "@tabler/icons-react-native";
|
|
3
|
+
import { cn } from "../cn";
|
|
4
|
+
import { cardStyle, sizeStyle, textStyle, typeStyle } from "../controlStyles";
|
|
5
|
+
import { palette, withAlpha } from "../palette";
|
|
6
|
+
import { useLargerText } from "../theme";
|
|
7
|
+
import { tokens } from "../tokens";
|
|
8
|
+
import { useScheme } from "../useScheme";
|
|
9
|
+
import { CardButton } from "./card-button";
|
|
10
|
+
import { chatTextSize, formatElapsed } from "./chat-text";
|
|
11
|
+
import {
|
|
12
|
+
Collapsible,
|
|
13
|
+
CollapsibleChevron,
|
|
14
|
+
CollapsibleContent,
|
|
15
|
+
CollapsibleTrigger,
|
|
16
|
+
useCollapsible,
|
|
17
|
+
} from "./collapsible";
|
|
18
|
+
import { ResponseText } from "./response-text";
|
|
19
|
+
|
|
20
|
+
export type SubagentStatus = "running" | "done" | "failed" | "cancelled";
|
|
21
|
+
|
|
22
|
+
const STATUS_LABEL: Record<SubagentStatus, string> = {
|
|
23
|
+
running: "Running",
|
|
24
|
+
done: "Done",
|
|
25
|
+
failed: "Failed",
|
|
26
|
+
cancelled: "Cancelled",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/** Pill colours per status. Text and border share one hue, the fill is a wash of it. */
|
|
30
|
+
export function subagentStatusColors(status: SubagentStatus, scheme: "light" | "dark") {
|
|
31
|
+
const hex =
|
|
32
|
+
status === "running"
|
|
33
|
+
? palette.primary[scheme]
|
|
34
|
+
: status === "done"
|
|
35
|
+
? palette.success[scheme]
|
|
36
|
+
: status === "failed"
|
|
37
|
+
? palette.danger[scheme]
|
|
38
|
+
: tokens.color.muted[scheme];
|
|
39
|
+
return { color: hex, borderColor: withAlpha(hex, 0.25), backgroundColor: withAlpha(hex, 0.12) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function StatusPill({ status, testID }: { status: SubagentStatus; testID: string }) {
|
|
43
|
+
const scheme = useScheme();
|
|
44
|
+
const largerText = useLargerText();
|
|
45
|
+
const caption = chatTextSize("caption", largerText);
|
|
46
|
+
const colors = subagentStatusColors(status, scheme);
|
|
47
|
+
return (
|
|
48
|
+
<View
|
|
49
|
+
testID={testID}
|
|
50
|
+
accessible
|
|
51
|
+
accessibilityLabel={`Status: ${STATUS_LABEL[status]}`}
|
|
52
|
+
className="rounded-full border px-2.5 py-0.5"
|
|
53
|
+
style={{ borderColor: colors.borderColor, backgroundColor: colors.backgroundColor }}
|
|
54
|
+
>
|
|
55
|
+
<Text className={cn(caption, "font-instrument-sans-semibold")} style={[sizeStyle(caption), typeStyle("semibold"), { color: colors.color }]}>
|
|
56
|
+
{STATUS_LABEL[status]}
|
|
57
|
+
</Text>
|
|
58
|
+
</View>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type SubagentRunCardProps = {
|
|
63
|
+
title: string;
|
|
64
|
+
status: SubagentStatus;
|
|
65
|
+
/** Whole seconds the run has taken. Shown as "12s" or "1m 5s". */
|
|
66
|
+
elapsedSeconds?: number;
|
|
67
|
+
/** Shows a Cancel button while `status` is `running`. */
|
|
68
|
+
onCancel?: () => void;
|
|
69
|
+
className?: string;
|
|
70
|
+
/** Default `subagent-run-card`. Parts add `-status`, `-elapsed` and `-cancel`. */
|
|
71
|
+
testID?: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** A subagent run: title, status pill, elapsed time, and Cancel while it runs. */
|
|
75
|
+
export function SubagentRunCard({
|
|
76
|
+
title,
|
|
77
|
+
status,
|
|
78
|
+
elapsedSeconds,
|
|
79
|
+
onCancel,
|
|
80
|
+
className,
|
|
81
|
+
testID = "subagent-run-card",
|
|
82
|
+
}: SubagentRunCardProps) {
|
|
83
|
+
const scheme = useScheme();
|
|
84
|
+
const largerText = useLargerText();
|
|
85
|
+
const body = chatTextSize("body", largerText);
|
|
86
|
+
const small = chatTextSize("small", largerText);
|
|
87
|
+
const elapsed = elapsedSeconds == null ? null : formatElapsed(elapsedSeconds);
|
|
88
|
+
return (
|
|
89
|
+
<View
|
|
90
|
+
testID={testID}
|
|
91
|
+
accessible={false}
|
|
92
|
+
accessibilityLabel={`Subagent ${title}, ${STATUS_LABEL[status]}`}
|
|
93
|
+
className={cn("w-full gap-3 p-4", className)}
|
|
94
|
+
style={cardStyle(scheme)}
|
|
95
|
+
>
|
|
96
|
+
<View className="flex-row items-center gap-2">
|
|
97
|
+
<IconRobot size={largerText ? 22 : 18} color={tokens.color.muted[scheme]} />
|
|
98
|
+
<Text
|
|
99
|
+
numberOfLines={2}
|
|
100
|
+
className={cn(body, "min-w-0 flex-1 font-instrument-sans-semibold")}
|
|
101
|
+
style={[sizeStyle(body), typeStyle("semibold"), textStyle("foreground", scheme)]}
|
|
102
|
+
>
|
|
103
|
+
{title}
|
|
104
|
+
</Text>
|
|
105
|
+
<StatusPill status={status} testID={`${testID}-status`} />
|
|
106
|
+
</View>
|
|
107
|
+
<View className="flex-row flex-wrap items-center justify-between gap-2">
|
|
108
|
+
{elapsed ? (
|
|
109
|
+
<Text
|
|
110
|
+
testID={`${testID}-elapsed`}
|
|
111
|
+
accessibilityLabel={`Elapsed ${elapsed}`}
|
|
112
|
+
className={small}
|
|
113
|
+
style={[sizeStyle(small), textStyle("muted", scheme), { fontVariant: ["tabular-nums"] }]}
|
|
114
|
+
>
|
|
115
|
+
{elapsed}
|
|
116
|
+
</Text>
|
|
117
|
+
) : (
|
|
118
|
+
<View />
|
|
119
|
+
)}
|
|
120
|
+
{status === "running" && onCancel ? (
|
|
121
|
+
<CardButton
|
|
122
|
+
testID={`${testID}-cancel`}
|
|
123
|
+
label="Cancel"
|
|
124
|
+
accessibilityLabel={`Cancel ${title}`}
|
|
125
|
+
variant="outline"
|
|
126
|
+
onPress={onCancel}
|
|
127
|
+
/>
|
|
128
|
+
) : null}
|
|
129
|
+
</View>
|
|
130
|
+
</View>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type SubagentResultCardProps = {
|
|
135
|
+
title: string;
|
|
136
|
+
/** The short result, always shown. */
|
|
137
|
+
summary: string;
|
|
138
|
+
/** The full result, shown when the card is open. No fold without it. */
|
|
139
|
+
detail?: string;
|
|
140
|
+
defaultOpen?: boolean;
|
|
141
|
+
open?: boolean;
|
|
142
|
+
onOpenChange?: (open: boolean) => void;
|
|
143
|
+
className?: string;
|
|
144
|
+
/** Default `subagent-result-card`. Parts add `-trigger` and `-detail`. */
|
|
145
|
+
testID?: string;
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
/** A subagent's result: the summary, and the detail behind a "Show details" fold. */
|
|
149
|
+
export function SubagentResultCard({
|
|
150
|
+
title,
|
|
151
|
+
summary,
|
|
152
|
+
detail,
|
|
153
|
+
defaultOpen = false,
|
|
154
|
+
open,
|
|
155
|
+
onOpenChange,
|
|
156
|
+
className,
|
|
157
|
+
testID = "subagent-result-card",
|
|
158
|
+
}: SubagentResultCardProps) {
|
|
159
|
+
const scheme = useScheme();
|
|
160
|
+
const largerText = useLargerText();
|
|
161
|
+
const body = chatTextSize("body", largerText);
|
|
162
|
+
const small = chatTextSize("small", largerText);
|
|
163
|
+
return (
|
|
164
|
+
<Collapsible
|
|
165
|
+
testID={testID}
|
|
166
|
+
defaultOpen={defaultOpen}
|
|
167
|
+
open={open}
|
|
168
|
+
onOpenChange={onOpenChange}
|
|
169
|
+
className={cn("w-full gap-2 p-4", className)}
|
|
170
|
+
style={cardStyle(scheme)}
|
|
171
|
+
>
|
|
172
|
+
<Text
|
|
173
|
+
numberOfLines={2}
|
|
174
|
+
className={cn(body, "font-instrument-sans-semibold")}
|
|
175
|
+
style={[sizeStyle(body), typeStyle("semibold"), textStyle("foreground", scheme)]}
|
|
176
|
+
>
|
|
177
|
+
{title}
|
|
178
|
+
</Text>
|
|
179
|
+
<ResponseText>{summary}</ResponseText>
|
|
180
|
+
{detail ? (
|
|
181
|
+
<>
|
|
182
|
+
<CollapsibleTrigger
|
|
183
|
+
testID={`${testID}-trigger`}
|
|
184
|
+
accessibilityLabel={`Details for ${title}`}
|
|
185
|
+
className="min-h-11 flex-row items-center gap-1.5 self-start"
|
|
186
|
+
>
|
|
187
|
+
<ToggleLabel small={small} />
|
|
188
|
+
</CollapsibleTrigger>
|
|
189
|
+
<CollapsibleContent testID={`${testID}-detail`}>
|
|
190
|
+
<ResponseText>{detail}</ResponseText>
|
|
191
|
+
</CollapsibleContent>
|
|
192
|
+
</>
|
|
193
|
+
) : null}
|
|
194
|
+
</Collapsible>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function ToggleLabel({ small }: { small: ReturnType<typeof chatTextSize> }) {
|
|
199
|
+
const { open } = useCollapsible();
|
|
200
|
+
const scheme = useScheme();
|
|
201
|
+
return (
|
|
202
|
+
<>
|
|
203
|
+
<Text className={cn(small, "font-instrument-sans")} style={[sizeStyle(small), textStyle("muted", scheme)]}>
|
|
204
|
+
{open ? "Hide details" : "Show details"}
|
|
205
|
+
</Text>
|
|
206
|
+
<CollapsibleChevron open={open} size={14} color={tokens.color.muted[scheme]} />
|
|
207
|
+
</>
|
|
208
|
+
);
|
|
209
|
+
}
|