@vvedantb/native-ui 0.12.1 → 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 CHANGED
@@ -514,12 +514,16 @@ import {
514
514
  | Part | Notes |
515
515
  | --- | --- |
516
516
  | `Conversation`, `ConversationContent`, `ConversationEmptyState`, `ConversationScrollButton`, `useConversation` | A `ScrollView` that sticks to the bottom: while the reader is within 70 of the end, new and growing messages stay in view. Once they drag up it stops following, and the scroll button shows: a round outline arrow (40, slop to 48), 16 above the bottom, with an opaque fill and no shadow. Pressing it scrolls down (a jump with Reduce Motion). For very long threads, drive a `FlatList` with `useConversation`. |
517
- | `Message`, `MessageContent`, `MessageActionsBar`, `MessageAction` | A user message is the web primary bubble (radius 16, bottom-right 14, `px-4 py-3`) at the end of the row; an assistant message has no fill. `MessageAction` is a ghost icon button at the web `icon-sm` size (32, slop to 44). `kind` gives the icon and label: "Copy", "Retry", "Good response", "Bad response". `selected` marks chosen feedback. |
517
+ | `Message`, `MessageContent`, `MessageActionsBar`, `MessageAction` | (0.13.0: `Message` `onLongPress`, `actions`, `highlighted`; `MessageContent` `quote`; the `reply` kind.) A user message is the web primary bubble (radius 16, bottom-right 14, `px-4 py-3`) at the end of the row; an assistant message has no fill. `MessageAction` is a ghost icon button at the web `icon-sm` size (32, slop to 44). `kind` gives the icon and label: "Copy", "Retry", "Good response", "Bad response". `selected` marks chosen feedback. |
518
518
  | `Reasoning`, `ReasoningTrigger`, `ReasoningContent`, `useReasoning` | Opens when streaming starts, times the stream and closes once, a second after it stops. The trigger shows a brain, the shimmering "Thinking…", then "Thought for 12s", and a chevron. The content is muted monospace text on the web `bg-surface-secondary/40` panel. Unlike the web, a reader can close it while it streams. |
519
519
  | `AgentTrace`, `AgentTraceLine`, `AgentTraceIcon`, `AgentTraceTitle`, `AgentTraceDescription`, `AgentTraceCollapsible*` | One step per line, with an optional fold behind a rail. `AgentTraceTitle shimmer` pulses a running step. Spread `useStreamingDisclosure(isStreaming)` on `AgentTraceCollapsible` to open it while a step streams. |
520
520
  | `ActivityStep` | One tool step as a trace line: icon, label, a spinner while `running`, a check when `complete`, a warning on `error`, and a description such as `` `Worked for ${formatElapsed(12)}` ``. |
521
521
  | `ChainOfThought` and its parts | Simplified: header, steps with a rail, search-result chips. |
522
522
  | `PromptInput`, `PromptInputButton`, `PromptInputSubmit` | The web field surface with a `header` slot (attachments), a field, a `toolbar` slot and the send button. The field grows from one line to 160 high (about 6 lines), then scrolls (auto-grow in both layouts from 0.12.1; send resets the height). Send is primary with text and disabled without. While `status` is `submitted` or `streaming` it is a "Stop" square that calls `onStop`. It sits in a `KeyboardAvoidingView` (padding, on iOS and Android) and pads the safe-area insets: put it last in a full-height column. Under a navigation header, pass the header height as `keyboardVerticalOffset`; above a tab bar, pass `safeBottom={false}`. |
523
+ | `MessageActionSheet`, `ReplyQuoteChip`, `MessageQuote` (0.13.0) | Quoted replies. Long-press a `Message` with `actions` to open the sheet; Reply first, then Copy. Pass `<ReplyQuoteChip author text onDismiss />` as `PromptInput` `replyTo`; send the reply with a `MessageQuote` in `MessageContent` `quote`. See [Quoted replies](#quoted-replies-0130). |
524
+ | `ToolApprovalCard`, `ToolDraftCard` (0.13.0) | A pending tool call: name, cut args summary, Deny and Approve (`ToolDraftCard` adds Edit). `onDecide("approve" \| "deny" \| "edit")`; `pending` or `disabled` turns the buttons off. |
525
+ | `SubagentRunCard`, `SubagentResultCard` (0.13.0) | A subagent run (title, status pill, elapsed, Cancel while `running`) and its result (summary, detail in a fold). |
526
+ | `MessageBotLabel`, `Message` `bot` (0.13.0) | Which bot sent an assistant message, for group chats: `bot={{ name, avatar }}`, or render `MessageBotLabel` above `MessageContent`. |
523
527
  | `PromptInput layout="inline"` | One row, as the web `Composer layout="inline"` (Eva's chat pill): `leading` (such as a + button), the field (one line to start, then it grows from 0.12.1), `toolbar`, then send, in a 20-radius field with `p-1.5`. 44 high on one line. |
524
528
  | `Suggestions`, `Suggestion` | A horizontal row of round chips (or `orientation="vertical"`). `onPress` gets the suggestion text. |
525
529
  | `Sources`, `SourcesTrigger`, `SourcesContent`, `Source` | A fold of link rows. A row opens its address with `Linking.openURL`, or hands it to `onPress`. |
@@ -542,6 +546,44 @@ Every control has a role, an accessible name and a 44 pt target. Folds report `e
542
546
 
543
547
  `@vvedantb/native-ui/ai-gallery` exports `AiChatGallery`, a full chat screen with a sample thread: a user message, a finished assistant message with folded reasoning and trace, code, sources and actions, and a second answer that streams with its reasoning open until you press Stop. It also has suggestions and the composer with sample attachments. Put it in a dev-only route with a `SafeAreaProvider` above it. `ParityGallery` shows the same thread (`AiChatSamples`) in its "AI chat" section.
544
548
 
549
+
550
+ ### Quoted replies (0.13.0)
551
+
552
+ Building blocks only: the app owns the reply state and the scroll. No new dependency. `expo-haptics` (optional peer) gives a selection haptic when the sheet opens.
553
+
554
+ ```tsx
555
+ import {
556
+ Message, MessageContent, MessageQuote, PromptInput, ReplyQuoteChip, MESSAGE_ACTIONS,
557
+ } from "@vvedantb/native-ui/ai";
558
+
559
+ <Message
560
+ from="user"
561
+ highlighted={highlightedId === item.id}
562
+ actions={[
563
+ { key: "reply", label: "Reply", icon: MESSAGE_ACTIONS.reply.icon, onPress: () => setReplyTo(item) },
564
+ { key: "copy", label: "Copy", icon: MESSAGE_ACTIONS.copy.icon, onPress: () => copy(item.text) },
565
+ { key: "delete", label: "Delete", destructive: true, onPress: () => remove(item) },
566
+ ]}
567
+ >
568
+ <MessageContent
569
+ quote={item.quoted ? <MessageQuote author={item.quoted.author} text={item.quoted.text} onPress={() => jumpTo(item.quoted.id)} /> : undefined}
570
+ >
571
+ {item.text}
572
+ </MessageContent>
573
+ </Message>
574
+
575
+ <PromptInput
576
+ onSubmit={send}
577
+ replyTo={replyTo ? <ReplyQuoteChip author={replyTo.author} text={replyTo.text} onDismiss={() => setReplyTo(null)} /> : undefined}
578
+ />
579
+ ```
580
+
581
+ - `Message`: `onLongPress`, `actions` (`{ key, label, icon?, destructive?, onPress }[]`), `actionSheetTitle`, `highlighted`. With neither `onLongPress` nor `actions` it is the same `View` as before. With either it is a `Pressable` with the same layout and view props, and it stays a non-accessible container so inner buttons keep focus: also offer Reply in a `MessageActionsBar` for screen reader users. `highlighted` draws a primary wash that fades over 1.4 seconds (`MESSAGE_HIGHLIGHT_MS`) and a ring while true; with Reduce Motion it holds still. Set it back to `false` after the scroll.
582
+ - `MessageActionSheet` (`open`, `onClose`, `actions`, `title`, `cancelLabel`, `testID`): an RN `Modal` bottom sheet, no `@gorhom/bottom-sheet`. Closes on the backdrop, Cancel, Android back and after an action. Destructive rows use the danger colour. Rows are 48 high (56 with larger text).
583
+ - `ReplyQuoteChip` (`author`, `text`, `onDismiss`, `dismissLabel`, `accessibilityLabel`, `testID`): author and up to two lines of text, with an X (44 point target, "Dismiss reply quote"). Labelled "Replying to {author}". `PromptInput` `replyTo` draws it above `header`; passing it as `header` works too.
584
+ - `MessageQuote` (`author`, `text`, `onPress`, `accessibilityLabel`, `testID`): a left border, the author and up to two lines. With `onPress` it is a button.
585
+ - Bot label in a group chat (0.13.0): `<Message from="assistant" bot={{ name: "Planner", avatar: <Avatar size="xs" /> }}>`. Or leave `Message` alone and render `<MessageBotLabel name="Planner" />` above `MessageContent` in `ChatThread` `renderMessage`. `ChatThread` needs no change.
586
+
545
587
  ## DropdownMenu (Unreleased)
546
588
 
547
589
  A small menu anchored to its trigger, the native counterpart of the web `DropdownMenu`. On `/primitives` and the root. From 0.12.1 the backdrop and menu sit in a full-size `View` inside the `Modal`, so the menu stays above tab bars and glass layers.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vvedantb/native-ui",
3
- "version": "0.12.1",
3
+ "version": "0.13.0",
4
4
  "license": "MIT",
5
5
  "description": "Shared React Native / Expo UI primitives. Floating surfaces, forms, overlays, toasts, settings chrome, tab bar, AI chat parts, the family app marks, a NativeWind preset, the Expo web root and Clerk + Convex auth screens.",
6
6
  "type": "module",
@@ -0,0 +1,49 @@
1
+ import { Pressable, Text } from "react-native";
2
+ import { cn } from "../cn";
3
+ import { buttonColors, MIN_TOUCH_TARGET, sizeStyle, touchSlop, typeStyle, type ButtonVariant } from "../controlStyles";
4
+ import { useLargerText } from "../theme";
5
+ import { useScheme } from "../useScheme";
6
+ import { chatTextSize } from "./chat-text";
7
+
8
+ export type CardButtonProps = {
9
+ label: string;
10
+ variant: Extract<ButtonVariant, "primary" | "outline" | "secondary" | "destructive" | "ghost">;
11
+ onPress?: () => void;
12
+ disabled?: boolean;
13
+ accessibilityLabel?: string;
14
+ testID?: string;
15
+ };
16
+
17
+ /**
18
+ * The compact pill button of the tool and subagent cards: at least 44 points
19
+ * high, label in the `small` chat size (steps up with larger text). Internal.
20
+ */
21
+ export function CardButton({ label, variant, onPress, disabled, accessibilityLabel, testID }: CardButtonProps) {
22
+ const scheme = useScheme();
23
+ const largerText = useLargerText();
24
+ const size = chatTextSize("small", largerText);
25
+ const colors = buttonColors(variant, scheme)!;
26
+ return (
27
+ <Pressable
28
+ testID={testID}
29
+ accessibilityRole="button"
30
+ accessibilityLabel={accessibilityLabel ?? label}
31
+ accessibilityState={{ disabled: !!disabled }}
32
+ disabled={disabled}
33
+ onPress={onPress}
34
+ hitSlop={touchSlop(MIN_TOUCH_TARGET)}
35
+ className={cn("items-center justify-center rounded-full border px-4 active:opacity-70")}
36
+ style={{
37
+ minHeight: MIN_TOUCH_TARGET,
38
+ minWidth: MIN_TOUCH_TARGET,
39
+ backgroundColor: colors.backgroundColor,
40
+ borderColor: colors.borderColor,
41
+ opacity: disabled ? 0.5 : 1,
42
+ }}
43
+ >
44
+ <Text className={cn(size, "font-instrument-sans-semibold")} style={[sizeStyle(size), typeStyle("semibold"), { color: colors.color }]}>
45
+ {label}
46
+ </Text>
47
+ </Pressable>
48
+ );
49
+ }
package/src/ai/index.ts CHANGED
@@ -35,7 +35,9 @@ export {
35
35
  MessageActionsBar,
36
36
  MessageAction,
37
37
  MessageMeta,
38
+ MessageBotLabel,
38
39
  MESSAGE_ACTIONS,
40
+ MESSAGE_HIGHLIGHT_MS,
39
41
  userBubbleStyle,
40
42
  tintBubbleStyle,
41
43
  } from "./message";
@@ -46,9 +48,17 @@ export type {
46
48
  MessageBubble,
47
49
  MessageActionsBarProps,
48
50
  MessageMetaProps,
51
+ MessageBot,
52
+ MessageBotLabelProps,
49
53
  MessageActionKind,
50
54
  MessageActionProps,
51
55
  } from "./message";
56
+ export { MessageActionSheet } from "./message-action-sheet";
57
+ export type { MessageActionSheetProps, MessageSheetAction } from "./message-action-sheet";
58
+ export { ReplyQuoteChip } from "./reply-quote-chip";
59
+ export type { ReplyQuoteChipProps } from "./reply-quote-chip";
60
+ export { MessageQuote, QUOTE_MAX_LINES } from "./message-quote";
61
+ export type { MessageQuoteProps } from "./message-quote";
52
62
  export {
53
63
  Reasoning,
54
64
  ReasoningTrigger,
@@ -136,3 +146,7 @@ export { ChoiceQuestion, choiceAnswers } from "./choice-question";
136
146
  export type { ChoiceQuestionProps, ChoiceQuestionItem } from "./choice-question";
137
147
  export { ActivityStepDetail } from "./activity-step-detail";
138
148
  export type { ActivityStepDetailProps, ActivityStepDetailStep } from "./activity-step-detail";
149
+ export { ToolApprovalCard, ToolDraftCard, summarizeArgs, ARGS_MAX_CHARS } from "./tool-approval-card";
150
+ export type { ToolApprovalCardProps, ToolDraftCardProps, ToolDecision } from "./tool-approval-card";
151
+ export { SubagentRunCard, SubagentResultCard, subagentStatusColors } from "./subagent-cards";
152
+ export type { SubagentRunCardProps, SubagentResultCardProps, SubagentStatus } from "./subagent-cards";
@@ -0,0 +1,165 @@
1
+ import { useEffect, type ComponentType } from "react";
2
+ import { Modal, Pressable, StyleSheet, Text, View } from "react-native";
3
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
4
+ import type { IconProps } from "@tabler/icons-react-native";
5
+ import { cn } from "../cn";
6
+ import { MIN_TOUCH_TARGET, sizeStyle, textStyle, touchSlop, typeStyle } from "../controlStyles";
7
+ import { selectionHaptic } from "../haptics";
8
+ import { palette } from "../palette";
9
+ import { useLargerText } from "../theme";
10
+ import { tokens } from "../tokens";
11
+ import { useReduceMotion } from "../useReduceMotion";
12
+ import { useScheme } from "../useScheme";
13
+ import { chatTextSize } from "./chat-text";
14
+
15
+ /** One row of `MessageActionSheet`. */
16
+ export type MessageSheetAction = {
17
+ key: string;
18
+ label: string;
19
+ icon?: ComponentType<IconProps>;
20
+ /** Draws the row in the danger colour, such as Delete. */
21
+ destructive?: boolean;
22
+ onPress: () => void;
23
+ };
24
+
25
+ export type MessageActionSheetProps = {
26
+ open: boolean;
27
+ /** Called on a backdrop tap, Cancel, Android back and after an action runs. */
28
+ onClose: () => void;
29
+ actions: MessageSheetAction[];
30
+ /** Shown above the rows, and names the sheet for VoiceOver and TalkBack. Default "Message actions". */
31
+ title?: string;
32
+ /** Default "Cancel". */
33
+ cancelLabel?: string;
34
+ testID?: string;
35
+ };
36
+
37
+ /**
38
+ * The sheet that opens on a long press of a chat message: a bottom sheet in
39
+ * an RN `Modal` (no `@gorhom/bottom-sheet`) with one pressable row per action
40
+ * and a Cancel row. It closes on the backdrop, Cancel, Android back and after
41
+ * an action. A `selectionHaptic()` fires when it opens (`expo-haptics` is an
42
+ * optional peer). Row testIDs are `{testID}-action-{key}`.
43
+ */
44
+ export function MessageActionSheet({
45
+ open,
46
+ onClose,
47
+ actions,
48
+ title = "Message actions",
49
+ cancelLabel = "Cancel",
50
+ testID = "message-action-sheet",
51
+ }: MessageActionSheetProps) {
52
+ const scheme = useScheme();
53
+ const largerText = useLargerText();
54
+ const insets = useSafeAreaInsets();
55
+ const reduceMotion = useReduceMotion();
56
+
57
+ useEffect(() => {
58
+ if (open) selectionHaptic();
59
+ }, [open]);
60
+
61
+ const caption = chatTextSize("caption", largerText);
62
+ const body = chatTextSize("body", largerText);
63
+ const minHeight = largerText ? 56 : MIN_TOUCH_TARGET + 4;
64
+
65
+ return (
66
+ <Modal
67
+ visible={open}
68
+ transparent
69
+ animationType={reduceMotion ? "none" : "fade"}
70
+ statusBarTranslucent
71
+ onRequestClose={onClose}
72
+ >
73
+ <Pressable
74
+ testID={`${testID}-backdrop`}
75
+ accessibilityRole="button"
76
+ accessibilityLabel="Close"
77
+ onPress={onClose}
78
+ hitSlop={touchSlop(MIN_TOUCH_TARGET)}
79
+ style={[StyleSheet.absoluteFill, { backgroundColor: palette.backdrop[scheme] }]}
80
+ />
81
+ <View pointerEvents="box-none" style={styles.anchor}>
82
+ <View
83
+ testID={testID}
84
+ accessibilityViewIsModal
85
+ accessibilityLabel={title}
86
+ onAccessibilityEscape={onClose}
87
+ className="w-full self-center px-2"
88
+ style={{ maxWidth: 480, paddingBottom: insets.bottom + 8 }}
89
+ >
90
+ <View
91
+ className="overflow-hidden"
92
+ style={{ backgroundColor: tokens.color.surface[scheme], borderRadius: tokens.radius.card }}
93
+ >
94
+ <Text
95
+ accessibilityRole="header"
96
+ numberOfLines={1}
97
+ className={cn(caption, "px-4 pb-1 pt-3 font-instrument-sans-medium")}
98
+ style={[sizeStyle(caption), typeStyle("medium"), textStyle("muted", scheme)]}
99
+ >
100
+ {title}
101
+ </Text>
102
+ {actions.map((action, index) => {
103
+ const Icon = action.icon;
104
+ const color = action.destructive ? palette.danger[scheme] : tokens.color.foreground[scheme];
105
+ return (
106
+ <Pressable
107
+ key={action.key}
108
+ testID={`${testID}-action-${action.key}`}
109
+ accessibilityRole="button"
110
+ accessibilityLabel={action.label}
111
+ hitSlop={touchSlop(MIN_TOUCH_TARGET)}
112
+ onPress={() => {
113
+ onClose();
114
+ action.onPress();
115
+ }}
116
+ className="flex-row items-center gap-3 px-4"
117
+ style={[
118
+ { minHeight },
119
+ index > 0 && {
120
+ borderTopWidth: StyleSheet.hairlineWidth,
121
+ borderTopColor: tokens.color.separator[scheme],
122
+ },
123
+ ]}
124
+ >
125
+ {Icon ? <Icon size={largerText ? 22 : 20} color={color} /> : null}
126
+ <Text
127
+ numberOfLines={1}
128
+ className={cn(body, "min-w-0 flex-1 font-instrument-sans-medium")}
129
+ style={[sizeStyle(body), typeStyle("medium"), { color }]}
130
+ >
131
+ {action.label}
132
+ </Text>
133
+ </Pressable>
134
+ );
135
+ })}
136
+ </View>
137
+ <Pressable
138
+ testID={`${testID}-cancel`}
139
+ accessibilityRole="button"
140
+ accessibilityLabel={cancelLabel}
141
+ onPress={onClose}
142
+ hitSlop={touchSlop(MIN_TOUCH_TARGET)}
143
+ className="mt-2 items-center justify-center"
144
+ style={{
145
+ minHeight,
146
+ backgroundColor: tokens.color.surface[scheme],
147
+ borderRadius: tokens.radius.card,
148
+ }}
149
+ >
150
+ <Text
151
+ className={cn(body, "font-instrument-sans-semibold")}
152
+ style={[sizeStyle(body), typeStyle("semibold"), textStyle("foreground", scheme)]}
153
+ >
154
+ {cancelLabel}
155
+ </Text>
156
+ </Pressable>
157
+ </View>
158
+ </View>
159
+ </Modal>
160
+ );
161
+ }
162
+
163
+ const styles = StyleSheet.create({
164
+ anchor: { flex: 1, justifyContent: "flex-end" },
165
+ });
@@ -0,0 +1,98 @@
1
+ import { useContext } from "react";
2
+ import { Pressable, Text, View } from "react-native";
3
+ import { cn } from "../cn";
4
+ import { MIN_TOUCH_TARGET, sizeStyle, textStyle, touchSlop, typeStyle } from "../controlStyles";
5
+ import { withAlpha } from "../palette";
6
+ import { useLargerText } from "../theme";
7
+ import { useScheme } from "../useScheme";
8
+ import { chatTextSize } from "./chat-text";
9
+ import { ChatTextColorContext } from "./response-text";
10
+
11
+ export type MessageQuoteProps = {
12
+ /** Who wrote the quoted message, such as "You" or the bot name. */
13
+ author: string;
14
+ /** The quoted text. Shown on up to two lines. */
15
+ text: string;
16
+ /** Makes the quote a button, so the app can scroll to and highlight the original. */
17
+ onPress?: () => void;
18
+ /** Names the button. Default "Go to quoted message from {author}". Used with `onPress` only. */
19
+ accessibilityLabel?: string;
20
+ className?: string;
21
+ testID?: string;
22
+ };
23
+
24
+ /** Lines of the quoted text before it is cut with an ellipsis. */
25
+ export const QUOTE_MAX_LINES = 2;
26
+
27
+ /**
28
+ * A compact quote inside a message bubble, above the message text: a left
29
+ * border, the author in the caption size and the quoted text on up to two
30
+ * lines. It takes its colour from the enclosing `MessageContent`, so it reads
31
+ * on the primary user bubble as well as on the tint and assistant ones. With
32
+ * `onPress` it is a button (44 point minimum height).
33
+ */
34
+ export function MessageQuote({
35
+ author,
36
+ text,
37
+ onPress,
38
+ accessibilityLabel,
39
+ className,
40
+ testID,
41
+ }: MessageQuoteProps) {
42
+ const scheme = useScheme();
43
+ const largerText = useLargerText();
44
+ const inherited = useContext(ChatTextColorContext);
45
+ const color = inherited ?? textStyle("foreground", scheme).color;
46
+ const caption = chatTextSize("caption", largerText);
47
+ const body = chatTextSize("small", largerText);
48
+ const content = (
49
+ <>
50
+ <Text
51
+ numberOfLines={1}
52
+ className={cn(caption, "font-instrument-sans-semibold")}
53
+ style={[sizeStyle(caption), typeStyle("semibold"), { color: withAlpha(color, 0.75) }]}
54
+ >
55
+ {author}
56
+ </Text>
57
+ <Text
58
+ numberOfLines={QUOTE_MAX_LINES}
59
+ className={cn(body, "font-instrument-sans")}
60
+ style={[sizeStyle(body), { color: withAlpha(color, 0.9) }]}
61
+ >
62
+ {text}
63
+ </Text>
64
+ </>
65
+ );
66
+ const style = {
67
+ borderLeftWidth: 3,
68
+ borderLeftColor: withAlpha(color, 0.45),
69
+ borderRadius: 4,
70
+ };
71
+ if (!onPress) {
72
+ return (
73
+ <View
74
+ testID={testID}
75
+ accessible
76
+ accessibilityLabel={`Quoted message from ${author}: ${text}`}
77
+ className={cn("min-w-0 gap-0.5 py-0.5 pl-2.5", className)}
78
+ style={style}
79
+ >
80
+ {content}
81
+ </View>
82
+ );
83
+ }
84
+ return (
85
+ <Pressable
86
+ testID={testID}
87
+ accessibilityRole="button"
88
+ accessibilityLabel={accessibilityLabel ?? `Go to quoted message from ${author}`}
89
+ accessibilityHint={text}
90
+ onPress={onPress}
91
+ hitSlop={touchSlop(MIN_TOUCH_TARGET)}
92
+ className={cn("min-h-11 min-w-0 justify-center gap-0.5 py-0.5 pl-2.5", className)}
93
+ style={style}
94
+ >
95
+ {content}
96
+ </Pressable>
97
+ );
98
+ }
@@ -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" },
@@ -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",
@@ -180,6 +187,7 @@ export function PromptInput({
180
187
  {layout === "inline" ? (
181
188
  // Web `Composer` inline: `rounded-[1.25rem]` and a `p-1.5` row, items at the bottom as the field grows.
182
189
  <View className="border" style={[fieldStyle(scheme), { borderRadius: INLINE_RADIUS }]}>
190
+ {replyTo ? <View className="px-1.5 pt-1.5">{replyTo}</View> : null}
183
191
  {header ? <View className="flex-row flex-wrap gap-1 px-1.5 pt-1.5">{header}</View> : null}
184
192
  <View className="flex-row items-end gap-1 p-1.5">
185
193
  {leading ? <View className="shrink-0 flex-row items-center gap-1">{leading}</View> : null}
@@ -215,6 +223,7 @@ export function PromptInput({
215
223
  </View>
216
224
  ) : (
217
225
  <View className="border" style={fieldStyle(scheme)}>
226
+ {replyTo ? <View className="px-2 pt-2">{replyTo}</View> : null}
218
227
  {header ? <View className="flex-row flex-wrap gap-1 px-2 pt-2">{header}</View> : null}
219
228
  {/* The `Textarea` field without its `numberOfLines` (default 4), which on Android sets the line count: this one starts at one line and grows. */}
220
229
  <FieldInput
@@ -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
+ }
@@ -0,0 +1,162 @@
1
+ import { Text, View } from "react-native";
2
+ import { IconTool } from "@tabler/icons-react-native";
3
+ import { cn } from "../cn";
4
+ import { cardStyle, sizeStyle, textStyle, typeStyle } from "../controlStyles";
5
+ import { withAlpha, palette } 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, MONO_FONT } from "./chat-text";
11
+
12
+ /** What the reader chose. `edit` is offered by `ToolDraftCard` only. */
13
+ export type ToolDecision = "approve" | "deny" | "edit";
14
+
15
+ export type ToolApprovalCardProps = {
16
+ /** The tool the model wants to run. */
17
+ toolName: string;
18
+ /** The arguments, as a short string or an object. Shown cut to `ARGS_MAX_CHARS`. */
19
+ args?: string | Record<string, unknown>;
20
+ /** Called with the choice. */
21
+ onDecide?: (decision: ToolDecision) => void;
22
+ /** True while the app sends the decision: the buttons are off and the card says so. */
23
+ pending?: boolean;
24
+ /** Turns the buttons off without the pending state. */
25
+ disabled?: boolean;
26
+ approveLabel?: string;
27
+ denyLabel?: string;
28
+ className?: string;
29
+ /** Default `tool-approval-card`. The buttons add `-approve`, `-deny` and `-edit`. */
30
+ testID?: string;
31
+ };
32
+
33
+ /** Characters of the args summary before it is cut with an ellipsis. */
34
+ export const ARGS_MAX_CHARS = 160;
35
+
36
+ /** One line summary of tool arguments, cut at `max` characters. */
37
+ export function summarizeArgs(args: string | Record<string, unknown> | undefined, max: number = ARGS_MAX_CHARS): string {
38
+ if (args == null) return "";
39
+ let text: string;
40
+ if (typeof args === "string") {
41
+ text = args;
42
+ } else {
43
+ try {
44
+ text = JSON.stringify(args) ?? "";
45
+ } catch {
46
+ text = "[unserialisable arguments]";
47
+ }
48
+ }
49
+ text = text.replace(/\s+/g, " ").trim();
50
+ return text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text;
51
+ }
52
+
53
+ type BaseProps = ToolApprovalCardProps & { allowEdit?: boolean; editLabel?: string; heading: string };
54
+
55
+ function ToolCardBase({
56
+ toolName,
57
+ args,
58
+ onDecide,
59
+ pending = false,
60
+ disabled = false,
61
+ approveLabel = "Approve",
62
+ denyLabel = "Deny",
63
+ editLabel = "Edit",
64
+ allowEdit = false,
65
+ heading,
66
+ className,
67
+ testID = "tool-approval-card",
68
+ }: BaseProps) {
69
+ const scheme = useScheme();
70
+ const largerText = useLargerText();
71
+ const body = chatTextSize("body", largerText);
72
+ const small = chatTextSize("small", largerText);
73
+ const caption = chatTextSize("caption", largerText);
74
+ const off = pending || disabled;
75
+ const summary = summarizeArgs(args);
76
+ return (
77
+ <View
78
+ testID={testID}
79
+ accessible={false}
80
+ accessibilityLabel={`${heading}: ${toolName}`}
81
+ className={cn("w-full gap-3 p-4", className)}
82
+ style={[
83
+ cardStyle(scheme),
84
+ { borderWidth: 1, borderColor: withAlpha(palette.primary[scheme], 0.25) },
85
+ ]}
86
+ >
87
+ <View className="flex-row items-center gap-2">
88
+ <IconTool size={largerText ? 20 : 16} color={tokens.color.muted[scheme]} />
89
+ <Text
90
+ className={cn(caption, "font-instrument-sans-semibold")}
91
+ style={[sizeStyle(caption), typeStyle("semibold"), textStyle("muted", scheme)]}
92
+ >
93
+ {pending ? "Sending decision…" : heading}
94
+ </Text>
95
+ </View>
96
+ <Text
97
+ testID={`${testID}-name`}
98
+ numberOfLines={2}
99
+ className={cn(body, "font-instrument-sans-semibold")}
100
+ style={[sizeStyle(body), typeStyle("semibold"), textStyle("foreground", scheme)]}
101
+ >
102
+ {toolName}
103
+ </Text>
104
+ {summary ? (
105
+ <Text
106
+ testID={`${testID}-args`}
107
+ numberOfLines={3}
108
+ className={small}
109
+ style={[sizeStyle(small), textStyle("muted", scheme), { fontFamily: MONO_FONT }]}
110
+ >
111
+ {summary}
112
+ </Text>
113
+ ) : null}
114
+ <View className="flex-row flex-wrap items-center justify-end gap-2" accessibilityState={{ busy: pending }}>
115
+ <CardButton
116
+ testID={`${testID}-deny`}
117
+ label={denyLabel}
118
+ accessibilityLabel={`${denyLabel} ${toolName}`}
119
+ variant="outline"
120
+ disabled={off}
121
+ onPress={() => onDecide?.("deny")}
122
+ />
123
+ {allowEdit ? (
124
+ <CardButton
125
+ testID={`${testID}-edit`}
126
+ label={editLabel}
127
+ accessibilityLabel={`${editLabel} ${toolName}`}
128
+ variant="secondary"
129
+ disabled={off}
130
+ onPress={() => onDecide?.("edit")}
131
+ />
132
+ ) : null}
133
+ <CardButton
134
+ testID={`${testID}-approve`}
135
+ label={approveLabel}
136
+ accessibilityLabel={`${approveLabel} ${toolName}`}
137
+ variant="primary"
138
+ disabled={off}
139
+ onPress={() => onDecide?.("approve")}
140
+ />
141
+ </View>
142
+ </View>
143
+ );
144
+ }
145
+
146
+ /**
147
+ * A pending tool call that needs a yes or no: the tool name, a cut summary of
148
+ * the arguments, and Deny and Approve buttons (44 point targets). With
149
+ * `pending` or `disabled` both buttons are off.
150
+ */
151
+ export function ToolApprovalCard(props: ToolApprovalCardProps) {
152
+ return <ToolCardBase {...props} heading="Approval needed" />;
153
+ }
154
+
155
+ export type ToolDraftCardProps = ToolApprovalCardProps & {
156
+ editLabel?: string;
157
+ };
158
+
159
+ /** As `ToolApprovalCard`, plus an Edit button that reports `"edit"`, for a draft the reader may change first. */
160
+ export function ToolDraftCard({ testID = "tool-draft-card", ...props }: ToolDraftCardProps) {
161
+ return <ToolCardBase {...props} testID={testID} allowEdit heading="Draft for review" />;
162
+ }