@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.
@@ -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({ from, className, style, ...props }: MessageProps) {
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
- <View
39
- className={cn("w-full max-w-[95%] gap-2", className)}
40
- style={[
41
- { alignSelf: user ? "flex-end" : "flex-start", alignItems: user ? "flex-end" : "stretch" },
42
- style,
43
- ]}
44
- {...props}
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
- {typeof children === "string" ? <ResponseText>{children}</ResponseText> : children}
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" },
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from "react";
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
- // One line to start (a multiline field is two rows on react-native-web); it grows from there.
188
- numberOfLines={1}
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: largerText ? MAX_FIELD_HEIGHT.larger : MAX_FIELD_HEIGHT.regular,
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: largerText ? MAX_FIELD_HEIGHT.larger : MAX_FIELD_HEIGHT.regular,
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
+ }