@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 +43 -1
- package/package.json +1 -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 +9 -0
- 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/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.
|
|
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
|
+
}
|
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
|
@@ -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
|
+
}
|