@vvedantb/native-ui 0.17.0 → 0.17.2
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 +16 -2
- package/package.json +1 -1
- package/src/AmbientBackground.tsx +1 -1
- package/src/BottomSheetContainer.tsx +90 -45
- package/src/DropdownMenu.tsx +2 -2
- package/src/ai/collapsible.tsx +7 -3
- package/src/ai/conversation.tsx +6 -2
- package/src/ai/index.ts +17 -3
- package/src/ai/prompt-input.tsx +2 -0
- package/src/ai/subagent-cards.tsx +84 -14
- package/src/ai/tool-approval-card.tsx +139 -8
- package/src/examples/AiChatGallery.tsx +70 -0
- package/src/primitives/select-host.tsx +55 -0
- package/src/primitives/select.tsx +58 -44
package/README.md
CHANGED
|
@@ -59,6 +59,20 @@ To hide a tab, give its screen Expo Router's `href: null`. From 0.3.9 `FloatingT
|
|
|
59
59
|
|
|
60
60
|
From 0.3.16 the focused tab sits on a rounded pill with the `TabsTrigger` pills outline, with the foreground label and icon; the other tabs are muted. From 0.3.17 the pill fill is `floatingTabPillColors(scheme)`: the muted `palette.default` (`--default`, `#ebebeb`) in light, so it shows on the near-white glass bar, and `palette.tabBarPill` (`#474747`, the kit `--segment`) in dark. `palette.tabBarPill` is its own key, so an app that themes `segment` (the segmented `Tabs` thumb) keeps a pill that shows on the dark glass bar. On the web the `--segment` pill sits on a `--default` track; here the bar is the raised surface, so the light pill takes the track fill. A route's `tabBarActiveTintColor` and `tabBarInactiveTintColor` still override the label and icon colours. The pill is one tab wide, inset 4 from the bar on every side, and slides to a newly focused tab over the web `--tabs-dur` (200 ms) and `--tabs-ease`. It counts only the tabs drawn, and there is none while a hidden route is focused.
|
|
61
61
|
|
|
62
|
+
## Gallery, Maestro and CI
|
|
63
|
+
|
|
64
|
+
`apps/native-gallery` (at the repository root) is an Expo SDK 57 app that renders every component from every entry point, with a layering stress screen (a tab bar over glass, with dropdowns, selects, sheets, dialogs and toasts over it) and a keyboard screen (`PromptInput`, inline and stacked). Clerk is mocked there, so it needs no keys. Run it:
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
pnpm install
|
|
68
|
+
pnpm --filter native-gallery start
|
|
69
|
+
# or cd apps/native-gallery && npx expo start
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
`tests/galleryRegistry.test.ts` fails when a React component export has no entry in `apps/native-gallery/src/galleryRegistry.ts`. Maestro flows in `apps/native-gallery/.maestro/` drive the dropdown, composer, sheets and select. The `native-ui-gallery-android` GitHub Actions job builds the gallery, boots an API 34 emulator and runs them for pull requests that touch this package or the gallery. A new or changed component needs a gallery entry, a Maestro flow and a hand test on an Android emulator.
|
|
73
|
+
|
|
74
|
+
The `Select` note stays: mount one `<PortalHost />` from `@rn-primitives/portal` at the app root, outside any `Modal`. The gallery does so in `app/_layout.tsx`.
|
|
75
|
+
|
|
62
76
|
## Entry points
|
|
63
77
|
|
|
64
78
|
The root entry exports everything. It still imports `BottomSheetContainer` and `DateTimeField`, so Metro resolves `@gorhom/bottom-sheet`, `@react-native-community/datetimepicker` and `dayjs` for any import from it. Apps without those peers, such as V Store, should import from the smaller entries below. Between them, they export every name the root exports.
|
|
@@ -542,8 +556,8 @@ import {
|
|
|
542
556
|
| `ChainOfThought` and its parts | Simplified: header, steps with a rail, search-result chips. |
|
|
543
557
|
| `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, and from 0.14.0 so does clearing the field). On web a multiline field is one row to start (0.14.0). Send is primary, and from 0.14.0 it stays primary and fades when there is nothing to send, as the web send button (before, it turned `secondary`). 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}`. |
|
|
544
558
|
| `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). |
|
|
545
|
-
| `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. |
|
|
546
|
-
| `SubagentRunCard`, `SubagentResultCard` (0.13.0) | A subagent run (title, status pill, elapsed, Cancel while `running`) and its result (summary, detail in a fold). |
|
|
559
|
+
| `ToolApprovalCard`, `ToolDraftCard` (0.13.0) | A pending tool call: name, cut args summary, Deny and Approve (`ToolDraftCard` adds Edit). `onDecide("approve" \| "deny" \| "edit", edits?)`; `pending` or `disabled` turns the buttons off. Optional chrome: `server`, `routineMode`, `expiresLabel`, `target`, `meta`, `chrome`. `ToolDraftCard` `fields` (`DraftField[]`) shows the draft and an inline editor under Edit; Approve then passes `changedDraftEdits` as `edits`. |
|
|
560
|
+
| `SubagentRunCard`, `SubagentResultCard` (0.13.0) | A subagent run (title, status pill, elapsed, Cancel while `queued` or `running`; statuses `queued`, `running`, `done`, `failed`, `cancelled`, `timed_out`; optional `statusLine`, `error`, `avatar`) and its result (summary, detail in a fold; optional `approvalPending`, `approvalPendingIn`, `handoffs`, `avatar`). |
|
|
547
561
|
| `MessageBotLabel`, `Message` `bot` (0.13.0) | Which bot sent an assistant message, for group chats: `bot={{ name, avatar }}`, or render `MessageBotLabel` above `MessageContent`. |
|
|
548
562
|
| `PromptInput layout="inline"` | One row, as the web `Composer layout="inline"`: `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`. The field is the web `text-sm leading-6` (14 / 24) from 0.14.0. `shape="pill"` (0.14.0) is Eva's web chat composer: 48 high on one line, radius 24, a 14 / 20 field 36 high and a 36 send button; give the + `variant="secondary"`. |
|
|
549
563
|
| `Suggestions`, `Suggestion` | A horizontal row of round chips (or `orientation="vertical"`). `onPress` gets the suggestion text. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vvedantb/native-ui",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.2",
|
|
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",
|
|
@@ -1,18 +1,76 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import BottomSheet, {
|
|
2
2
|
BottomSheetBackdrop,
|
|
3
|
-
BottomSheetModal,
|
|
4
3
|
BottomSheetView,
|
|
5
4
|
} from "@gorhom/bottom-sheet";
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
5
|
+
import type { BottomSheetDefaultBackdropProps } from "@gorhom/bottom-sheet/lib/typescript/components/bottomSheetBackdrop/types";
|
|
6
|
+
import { IconX } from "@tabler/icons-react-native";
|
|
7
|
+
import React, { useCallback } from "react";
|
|
8
|
+
import {
|
|
9
|
+
Platform,
|
|
10
|
+
Text,
|
|
11
|
+
TouchableOpacity,
|
|
12
|
+
View,
|
|
13
|
+
type ViewProps,
|
|
14
|
+
} from "react-native";
|
|
8
15
|
import { Colors } from "./colors";
|
|
9
16
|
import { textStyle, typeStyle } from "./controlStyles";
|
|
10
|
-
import {
|
|
11
|
-
import { BottomSheetDefaultBackdropProps } from "@gorhom/bottom-sheet/lib/typescript/components/bottomSheetBackdrop/types";
|
|
17
|
+
import { floatingSolidFill } from "./floating";
|
|
12
18
|
import { GlassSheetBackground } from "./LiquidGlass";
|
|
13
19
|
import { useLargerText } from "./theme";
|
|
14
20
|
import { useScheme } from "./useScheme";
|
|
15
21
|
|
|
22
|
+
// One snap point, set explicitly. With dynamic sizing the sheet can measure to
|
|
23
|
+
// height 0 on Android and never appear.
|
|
24
|
+
const SNAP_POINTS = ["40%"];
|
|
25
|
+
const SHEET_RADIUS = 20;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Opaque sheet chrome for Android. The glass background needs a measured blur
|
|
29
|
+
* target, which is not reliable once an RN `Modal` has been dismissed.
|
|
30
|
+
*/
|
|
31
|
+
function SolidSheetBackground({
|
|
32
|
+
style,
|
|
33
|
+
pointerEvents,
|
|
34
|
+
}: {
|
|
35
|
+
style?: ViewProps["style"];
|
|
36
|
+
pointerEvents?: ViewProps["pointerEvents"];
|
|
37
|
+
}) {
|
|
38
|
+
const scheme = useScheme();
|
|
39
|
+
return (
|
|
40
|
+
<View
|
|
41
|
+
pointerEvents={pointerEvents}
|
|
42
|
+
style={[
|
|
43
|
+
style,
|
|
44
|
+
{
|
|
45
|
+
backgroundColor: floatingSolidFill[scheme],
|
|
46
|
+
borderTopLeftRadius: SHEET_RADIUS,
|
|
47
|
+
borderTopRightRadius: SHEET_RADIUS,
|
|
48
|
+
},
|
|
49
|
+
]}
|
|
50
|
+
/>
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function SheetBackdrop(props: BottomSheetDefaultBackdropProps) {
|
|
55
|
+
return (
|
|
56
|
+
<BottomSheetBackdrop
|
|
57
|
+
{...props}
|
|
58
|
+
appearsOnIndex={0}
|
|
59
|
+
disappearsOnIndex={-1}
|
|
60
|
+
opacity={0.5}
|
|
61
|
+
/>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A controlled bottom sheet over the screen, built on gorhom's default-export
|
|
67
|
+
* `BottomSheet` (the named `BottomSheet` export is undefined).
|
|
68
|
+
*
|
|
69
|
+
* It renders nothing while `visible` is false. A closed gorhom sheet still lays
|
|
70
|
+
* out an absolute-fill host that can cover the triggers beneath it, and
|
|
71
|
+
* `BottomSheetModal.present()` did nothing after an RN `Modal` had been
|
|
72
|
+
* dismissed on Android.
|
|
73
|
+
*/
|
|
16
74
|
export function BottomSheetContainer({
|
|
17
75
|
visible,
|
|
18
76
|
onClose,
|
|
@@ -26,53 +84,43 @@ export function BottomSheetContainer({
|
|
|
26
84
|
children: React.ReactNode;
|
|
27
85
|
closeTestID?: string;
|
|
28
86
|
}) {
|
|
29
|
-
const bottomSheetModalRef = useRef<BottomSheetModal>(null);
|
|
30
87
|
const largerText = useLargerText();
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
useEffect(() => {
|
|
34
|
-
if (visible) {
|
|
35
|
-
bottomSheetModalRef.current?.present();
|
|
36
|
-
} else {
|
|
37
|
-
bottomSheetModalRef.current?.dismiss();
|
|
38
|
-
}
|
|
39
|
-
}, [visible]);
|
|
40
|
-
|
|
88
|
+
const isDark = useScheme() === "dark";
|
|
41
89
|
const renderBackdrop = useCallback(
|
|
42
|
-
(props:
|
|
43
|
-
<BottomSheetBackdrop
|
|
44
|
-
{...props}
|
|
45
|
-
disappearsOnIndex={-1}
|
|
46
|
-
appearsOnIndex={0}
|
|
47
|
-
opacity={0.5}
|
|
48
|
-
/>
|
|
49
|
-
),
|
|
90
|
+
(props: BottomSheetDefaultBackdropProps) => <SheetBackdrop {...props} />,
|
|
50
91
|
[],
|
|
51
92
|
);
|
|
52
93
|
|
|
94
|
+
if (!visible) {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
|
|
53
98
|
return (
|
|
54
|
-
<
|
|
55
|
-
|
|
99
|
+
<BottomSheet
|
|
100
|
+
index={0}
|
|
101
|
+
snapPoints={SNAP_POINTS}
|
|
102
|
+
enableDynamicSizing={false}
|
|
103
|
+
enablePanDownToClose
|
|
104
|
+
enableContentPanningGesture={false}
|
|
105
|
+
enableHandlePanningGesture
|
|
106
|
+
keyboardBehavior="interactive"
|
|
107
|
+
onClose={onClose}
|
|
56
108
|
backdropComponent={renderBackdrop}
|
|
57
|
-
|
|
58
|
-
|
|
109
|
+
backgroundComponent={
|
|
110
|
+
Platform.OS === "android" ? SolidSheetBackground : GlassSheetBackground
|
|
111
|
+
}
|
|
59
112
|
handleIndicatorStyle={{
|
|
60
|
-
backgroundColor:
|
|
61
|
-
colorScheme === "dark" ? Colors.white : Colors.neutral[800],
|
|
113
|
+
backgroundColor: isDark ? Colors.white : Colors.neutral[800],
|
|
62
114
|
}}
|
|
63
|
-
enableContentPanningGesture={false}
|
|
64
|
-
enableHandlePanningGesture={true}
|
|
65
|
-
keyboardBehavior="interactive"
|
|
66
115
|
>
|
|
67
|
-
|
|
116
|
+
{/* Padding is a style, not a NativeWind class: `BottomSheetView` does not
|
|
117
|
+
always apply `className`, and an unpadded sheet can measure too small. */}
|
|
118
|
+
<BottomSheetView style={{ padding: 20, paddingBottom: 48 }}>
|
|
68
119
|
<View className="mb-4 flex-row items-center justify-between gap-3">
|
|
69
120
|
<Text
|
|
70
121
|
accessibilityRole="header"
|
|
71
122
|
className={`${largerText ? "text-2xl" : "text-xl"} flex-1 font-instrument-sans-semibold`}
|
|
72
|
-
style={[
|
|
73
|
-
textStyle("foreground", colorScheme === "dark" ? "dark" : "light"),
|
|
74
|
-
typeStyle("semibold"),
|
|
75
|
-
]}
|
|
123
|
+
style={[textStyle("foreground", isDark ? "dark" : "light"), typeStyle("semibold")]}
|
|
76
124
|
>
|
|
77
125
|
{title}
|
|
78
126
|
</Text>
|
|
@@ -80,19 +128,16 @@ export function BottomSheetContainer({
|
|
|
80
128
|
testID={closeTestID}
|
|
81
129
|
accessibilityRole="button"
|
|
82
130
|
accessibilityLabel="Close"
|
|
83
|
-
onPress={
|
|
131
|
+
onPress={onClose}
|
|
84
132
|
className="-mr-2 h-12 w-12 items-center justify-center"
|
|
85
133
|
hitSlop={8}
|
|
86
134
|
activeOpacity={0.7}
|
|
87
135
|
>
|
|
88
|
-
<IconX
|
|
89
|
-
size={largerText ? 32 : 28}
|
|
90
|
-
color={colorScheme === "dark" ? "white" : "black"}
|
|
91
|
-
/>
|
|
136
|
+
<IconX size={largerText ? 32 : 28} color={isDark ? "white" : "black"} />
|
|
92
137
|
</TouchableOpacity>
|
|
93
138
|
</View>
|
|
94
139
|
{children}
|
|
95
140
|
</BottomSheetView>
|
|
96
|
-
</
|
|
141
|
+
</BottomSheet>
|
|
97
142
|
);
|
|
98
143
|
}
|
package/src/DropdownMenu.tsx
CHANGED
|
@@ -28,7 +28,7 @@ import { useControllableState } from "./primitives/use-controllable-state";
|
|
|
28
28
|
/**
|
|
29
29
|
* Sizes from the web menu classes (`_menu-classes.ts`): content `min-w-[12rem]
|
|
30
30
|
* p-1.5 rounded-lg`, `sideOffset` 4 and `collisionPadding` 8; items `px-2.5
|
|
31
|
-
* py-
|
|
31
|
+
* py-3 rounded-menu-item` (8) with a 16 icon `gap-2`; radio items `pl-8` with
|
|
32
32
|
* the check at `left-2.5`; the label `px-2.5 py-1.5`; the separator `-mx-1.5
|
|
33
33
|
* my-1 h-px`. Items are at least 48 high, a phone menu row (the web
|
|
34
34
|
* `max-sm:min-h-10` is 40).
|
|
@@ -42,7 +42,7 @@ export const DROPDOWN_MENU = {
|
|
|
42
42
|
itemHeight: 48,
|
|
43
43
|
itemRadius: 8,
|
|
44
44
|
itemPaddingX: 10,
|
|
45
|
-
itemPaddingY:
|
|
45
|
+
itemPaddingY: 12,
|
|
46
46
|
itemGap: 8,
|
|
47
47
|
indicatorInset: 32,
|
|
48
48
|
indicatorLeft: 10,
|
package/src/ai/collapsible.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createContext, useContext, type ReactNode } from "react";
|
|
2
2
|
import { Pressable, View, type PressableProps, type ViewProps } from "react-native";
|
|
3
3
|
import { IconChevronDown, IconChevronRight } from "@tabler/icons-react-native";
|
|
4
|
-
import Animated
|
|
4
|
+
import Animated from "react-native-reanimated";
|
|
5
5
|
import { cn } from "../cn";
|
|
6
6
|
import { MIN_TOUCH_TARGET, touchSlop } from "../controlStyles";
|
|
7
7
|
import { useControllableState } from "../primitives/use-controllable-state";
|
|
@@ -13,11 +13,15 @@ import { useReduceMotion } from "../useReduceMotion";
|
|
|
13
13
|
// opening, a virtualised row scrolled back). The chevron turns in 150 ms; with
|
|
14
14
|
// Reduce Motion it changes in place.
|
|
15
15
|
|
|
16
|
-
/**
|
|
16
|
+
/**
|
|
17
|
+
* The web chevron: `transition-transform duration-150`. The curve is the
|
|
18
|
+
* predefined `ease-out`: a `cubicBezier(...)` value in an `Animated.View`
|
|
19
|
+
* style does not typecheck against `react-native-reanimated` 4.3.
|
|
20
|
+
*/
|
|
17
21
|
const CHEVRON_TURN = {
|
|
18
22
|
transitionProperty: "transform",
|
|
19
23
|
transitionDuration: 150,
|
|
20
|
-
transitionTimingFunction:
|
|
24
|
+
transitionTimingFunction: "ease-out",
|
|
21
25
|
} as const;
|
|
22
26
|
|
|
23
27
|
type CollapsibleContextValue = { open: boolean; setOpen: (open: boolean) => void };
|
package/src/ai/conversation.tsx
CHANGED
|
@@ -62,9 +62,13 @@ export function isScrolledToBottom(
|
|
|
62
62
|
/**
|
|
63
63
|
* What the thread scrolls with: the `ScrollView` of `ConversationContent` or
|
|
64
64
|
* the `FlatList` of `ConversationList`. `scrollToEnd` goes to the newest
|
|
65
|
-
* message, which is offset 0 in an inverted list.
|
|
65
|
+
* message, which is offset 0 in an inverted list. `scrollTo` goes to an
|
|
66
|
+
* offset; only the `ScrollView` has it, so call it as `scrollTo?.(...)`.
|
|
66
67
|
*/
|
|
67
|
-
export type ConversationScroller = {
|
|
68
|
+
export type ConversationScroller = {
|
|
69
|
+
scrollToEnd: (options?: { animated?: boolean }) => void;
|
|
70
|
+
scrollTo?: (options: { y: number; animated?: boolean }) => void;
|
|
71
|
+
};
|
|
68
72
|
|
|
69
73
|
type ConversationContextValue = {
|
|
70
74
|
isAtBottom: boolean;
|
package/src/ai/index.ts
CHANGED
|
@@ -183,7 +183,21 @@ export { ActivityStepDetail } from "./activity-step-detail";
|
|
|
183
183
|
export type { ActivityStepDetailProps, ActivityStepDetailStep } from "./activity-step-detail";
|
|
184
184
|
export { UnifiedDiff, DiffStat, UNIFIED_DIFF } from "./unified-diff";
|
|
185
185
|
export type { UnifiedDiffProps, UnifiedDiffLine, DiffStatProps } from "./unified-diff";
|
|
186
|
-
export {
|
|
187
|
-
|
|
186
|
+
export {
|
|
187
|
+
ToolApprovalCard,
|
|
188
|
+
ToolDraftCard,
|
|
189
|
+
summarizeArgs,
|
|
190
|
+
ARGS_MAX_CHARS,
|
|
191
|
+
changedDraftEdits,
|
|
192
|
+
DRAFT_ROLE_LABELS,
|
|
193
|
+
} from "./tool-approval-card";
|
|
194
|
+
export type {
|
|
195
|
+
ToolApprovalCardProps,
|
|
196
|
+
ToolDraftCardProps,
|
|
197
|
+
ToolDecision,
|
|
198
|
+
DraftField,
|
|
199
|
+
DraftFieldRole,
|
|
200
|
+
DraftEdit,
|
|
201
|
+
} from "./tool-approval-card";
|
|
188
202
|
export { SubagentRunCard, SubagentResultCard, subagentStatusColors } from "./subagent-cards";
|
|
189
|
-
export type { SubagentRunCardProps, SubagentResultCardProps, SubagentStatus } from "./subagent-cards";
|
|
203
|
+
export type { SubagentRunCardProps, SubagentResultCardProps, SubagentStatus, SubagentHandoff } from "./subagent-cards";
|
package/src/ai/prompt-input.tsx
CHANGED
|
@@ -394,6 +394,7 @@ export function PromptInput({
|
|
|
394
394
|
</View>
|
|
395
395
|
) : null}
|
|
396
396
|
<FieldInput
|
|
397
|
+
testID={testID ? `${testID}-input` : undefined}
|
|
397
398
|
nativeID={COMPOSER_INPUT_ID}
|
|
398
399
|
multiline
|
|
399
400
|
numberOfLines={ONE_ROW}
|
|
@@ -440,6 +441,7 @@ export function PromptInput({
|
|
|
440
441
|
{header ? <View className="flex-row flex-wrap gap-1 px-2 pt-2">{header}</View> : null}
|
|
441
442
|
{/* The `Textarea` field without its `numberOfLines` (default 4), which on Android sets the line count: this one starts at one line and grows. */}
|
|
442
443
|
<FieldInput
|
|
444
|
+
testID={testID ? `${testID}-input` : undefined}
|
|
443
445
|
nativeID={COMPOSER_INPUT_ID}
|
|
444
446
|
multiline
|
|
445
447
|
numberOfLines={ONE_ROW}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
1
2
|
import { Text, View } from "react-native";
|
|
2
3
|
import { IconRobot } from "@tabler/icons-react-native";
|
|
3
4
|
import { cn } from "../cn";
|
|
@@ -6,8 +7,9 @@ import { palette, withAlpha } from "../palette";
|
|
|
6
7
|
import { useLargerText } from "../theme";
|
|
7
8
|
import { tokens } from "../tokens";
|
|
8
9
|
import { useScheme } from "../useScheme";
|
|
10
|
+
import { Spinner } from "../Spinner";
|
|
9
11
|
import { CardButton } from "./card-button";
|
|
10
|
-
import { chatTextSize, formatElapsed } from "./chat-text";
|
|
12
|
+
import { chatTextSize, formatElapsed, MONO_FONT } from "./chat-text";
|
|
11
13
|
import {
|
|
12
14
|
Collapsible,
|
|
13
15
|
CollapsibleChevron,
|
|
@@ -17,23 +19,25 @@ import {
|
|
|
17
19
|
} from "./collapsible";
|
|
18
20
|
import { ResponseText } from "./response-text";
|
|
19
21
|
|
|
20
|
-
export type SubagentStatus = "running" | "done" | "failed" | "cancelled";
|
|
22
|
+
export type SubagentStatus = "queued" | "running" | "done" | "failed" | "cancelled" | "timed_out";
|
|
21
23
|
|
|
22
24
|
const STATUS_LABEL: Record<SubagentStatus, string> = {
|
|
25
|
+
queued: "Queued",
|
|
23
26
|
running: "Running",
|
|
24
27
|
done: "Done",
|
|
25
28
|
failed: "Failed",
|
|
26
29
|
cancelled: "Cancelled",
|
|
30
|
+
timed_out: "Timed out",
|
|
27
31
|
};
|
|
28
32
|
|
|
29
33
|
/** Pill colours per status. Text and border share one hue, the fill is a wash of it. */
|
|
30
34
|
export function subagentStatusColors(status: SubagentStatus, scheme: "light" | "dark") {
|
|
31
35
|
const hex =
|
|
32
|
-
status === "running"
|
|
36
|
+
status === "running" || status === "queued"
|
|
33
37
|
? palette.primary[scheme]
|
|
34
38
|
: status === "done"
|
|
35
39
|
? palette.success[scheme]
|
|
36
|
-
: status === "failed"
|
|
40
|
+
: status === "failed" || status === "timed_out"
|
|
37
41
|
? palette.danger[scheme]
|
|
38
42
|
: tokens.color.muted[scheme];
|
|
39
43
|
return { color: hex, borderColor: withAlpha(hex, 0.25), backgroundColor: withAlpha(hex, 0.12) };
|
|
@@ -64,8 +68,14 @@ export type SubagentRunCardProps = {
|
|
|
64
68
|
status: SubagentStatus;
|
|
65
69
|
/** Whole seconds the run has taken. Shown as "12s" or "1m 5s". */
|
|
66
70
|
elapsedSeconds?: number;
|
|
67
|
-
/** Shows a Cancel button while `status` is `running`. */
|
|
71
|
+
/** Shows a Cancel button while `status` is `queued` or `running`. */
|
|
68
72
|
onCancel?: () => void;
|
|
73
|
+
/** Muted detail under the title, such as step or progress text. */
|
|
74
|
+
statusLine?: string;
|
|
75
|
+
/** Error text, in the danger colour. */
|
|
76
|
+
error?: string;
|
|
77
|
+
/** Replaces the robot icon. While `queued` or `running` a spinner shows instead. */
|
|
78
|
+
avatar?: ReactNode;
|
|
69
79
|
className?: string;
|
|
70
80
|
/** Default `subagent-run-card`. Parts add `-status`, `-elapsed` and `-cancel`. */
|
|
71
81
|
testID?: string;
|
|
@@ -77,6 +87,9 @@ export function SubagentRunCard({
|
|
|
77
87
|
status,
|
|
78
88
|
elapsedSeconds,
|
|
79
89
|
onCancel,
|
|
90
|
+
statusLine,
|
|
91
|
+
error,
|
|
92
|
+
avatar,
|
|
80
93
|
className,
|
|
81
94
|
testID = "subagent-run-card",
|
|
82
95
|
}: SubagentRunCardProps) {
|
|
@@ -94,7 +107,11 @@ export function SubagentRunCard({
|
|
|
94
107
|
style={cardStyle(scheme)}
|
|
95
108
|
>
|
|
96
109
|
<View className="flex-row items-center gap-2">
|
|
97
|
-
|
|
110
|
+
{status === "queued" || status === "running" ? (
|
|
111
|
+
<Spinner size="sm" decorative />
|
|
112
|
+
) : (
|
|
113
|
+
(avatar ?? <IconRobot size={largerText ? 22 : 18} color={tokens.color.muted[scheme]} />)
|
|
114
|
+
)}
|
|
98
115
|
<Text
|
|
99
116
|
numberOfLines={2}
|
|
100
117
|
className={cn(body, "min-w-0 flex-1 font-instrument-sans-semibold")}
|
|
@@ -104,6 +121,16 @@ export function SubagentRunCard({
|
|
|
104
121
|
</Text>
|
|
105
122
|
<StatusPill status={status} testID={`${testID}-status`} />
|
|
106
123
|
</View>
|
|
124
|
+
{statusLine ? (
|
|
125
|
+
<Text testID={`${testID}-status-line`} className={small} style={[sizeStyle(small), textStyle("muted", scheme)]}>
|
|
126
|
+
{statusLine}
|
|
127
|
+
</Text>
|
|
128
|
+
) : null}
|
|
129
|
+
{error ? (
|
|
130
|
+
<Text testID={`${testID}-error`} className={small} style={[sizeStyle(small), { color: palette.danger[scheme] }]}>
|
|
131
|
+
{error}
|
|
132
|
+
</Text>
|
|
133
|
+
) : null}
|
|
107
134
|
<View className="flex-row flex-wrap items-center justify-between gap-2">
|
|
108
135
|
{elapsed ? (
|
|
109
136
|
<Text
|
|
@@ -117,7 +144,7 @@ export function SubagentRunCard({
|
|
|
117
144
|
) : (
|
|
118
145
|
<View />
|
|
119
146
|
)}
|
|
120
|
-
{status === "running" && onCancel ? (
|
|
147
|
+
{(status === "running" || status === "queued") && onCancel ? (
|
|
121
148
|
<CardButton
|
|
122
149
|
testID={`${testID}-cancel`}
|
|
123
150
|
label="Cancel"
|
|
@@ -131,12 +158,26 @@ export function SubagentRunCard({
|
|
|
131
158
|
);
|
|
132
159
|
}
|
|
133
160
|
|
|
161
|
+
export type SubagentHandoff = {
|
|
162
|
+
toolName: string;
|
|
163
|
+
server?: string;
|
|
164
|
+
argsSummary: string;
|
|
165
|
+
};
|
|
166
|
+
|
|
134
167
|
export type SubagentResultCardProps = {
|
|
135
168
|
title: string;
|
|
136
169
|
/** The short result, always shown. */
|
|
137
170
|
summary: string;
|
|
138
171
|
/** The full result, shown when the card is open. No fold without it. */
|
|
139
172
|
detail?: string;
|
|
173
|
+
/** Shows the warning note that an approval waits in another chat. */
|
|
174
|
+
approvalPending?: boolean;
|
|
175
|
+
/** Name for `Approval pending in {name}'s chat.` Default "the other bot". */
|
|
176
|
+
approvalPendingIn?: string;
|
|
177
|
+
/** Proposed actions, listed under "Proposed actions". */
|
|
178
|
+
handoffs?: readonly SubagentHandoff[];
|
|
179
|
+
/** A leading avatar beside the title. */
|
|
180
|
+
avatar?: ReactNode;
|
|
140
181
|
defaultOpen?: boolean;
|
|
141
182
|
open?: boolean;
|
|
142
183
|
onOpenChange?: (open: boolean) => void;
|
|
@@ -150,6 +191,10 @@ export function SubagentResultCard({
|
|
|
150
191
|
title,
|
|
151
192
|
summary,
|
|
152
193
|
detail,
|
|
194
|
+
approvalPending = false,
|
|
195
|
+
approvalPendingIn,
|
|
196
|
+
handoffs,
|
|
197
|
+
avatar,
|
|
153
198
|
defaultOpen = false,
|
|
154
199
|
open,
|
|
155
200
|
onOpenChange,
|
|
@@ -169,14 +214,39 @@ export function SubagentResultCard({
|
|
|
169
214
|
className={cn("w-full gap-2 p-4", className)}
|
|
170
215
|
style={cardStyle(scheme)}
|
|
171
216
|
>
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
217
|
+
<View className="flex-row items-center gap-2">
|
|
218
|
+
{avatar}
|
|
219
|
+
<Text
|
|
220
|
+
numberOfLines={2}
|
|
221
|
+
className={cn(body, "min-w-0 flex-1 font-instrument-sans-semibold")}
|
|
222
|
+
style={[sizeStyle(body), typeStyle("semibold"), textStyle("foreground", scheme)]}
|
|
223
|
+
>
|
|
224
|
+
{title}
|
|
225
|
+
</Text>
|
|
226
|
+
</View>
|
|
179
227
|
<ResponseText>{summary}</ResponseText>
|
|
228
|
+
{approvalPending ? (
|
|
229
|
+
<Text testID={`${testID}-approval-pending`} className={small} style={[sizeStyle(small), { color: palette.warning[scheme] }]}>
|
|
230
|
+
{`Approval pending in ${approvalPendingIn ? `${approvalPendingIn}'s` : "the other bot's"} chat.`}
|
|
231
|
+
</Text>
|
|
232
|
+
) : null}
|
|
233
|
+
{handoffs && handoffs.length > 0 ? (
|
|
234
|
+
<View testID={`${testID}-handoffs`} className="gap-1">
|
|
235
|
+
<Text className={small} style={[sizeStyle(small), textStyle("muted", scheme)]}>
|
|
236
|
+
Proposed actions
|
|
237
|
+
</Text>
|
|
238
|
+
{handoffs.map((handoff, index) => (
|
|
239
|
+
<Text
|
|
240
|
+
key={`${handoff.toolName}-${index}`}
|
|
241
|
+
testID={`${testID}-handoff-${index}`}
|
|
242
|
+
className={small}
|
|
243
|
+
style={[sizeStyle(small), textStyle("muted", scheme), { fontFamily: MONO_FONT }]}
|
|
244
|
+
>
|
|
245
|
+
{`${handoff.toolName}${handoff.server ? ` via ${handoff.server}` : ""}: ${handoff.argsSummary}`}
|
|
246
|
+
</Text>
|
|
247
|
+
))}
|
|
248
|
+
</View>
|
|
249
|
+
) : null}
|
|
180
250
|
{detail ? (
|
|
181
251
|
<>
|
|
182
252
|
<CollapsibleTrigger
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useState, type ReactNode } from "react";
|
|
1
2
|
import { Text, View } from "react-native";
|
|
2
3
|
import { IconTool } from "@tabler/icons-react-native";
|
|
3
4
|
import { cn } from "../cn";
|
|
@@ -6,9 +7,34 @@ import { withAlpha, palette } from "../palette";
|
|
|
6
7
|
import { useLargerText } from "../theme";
|
|
7
8
|
import { tokens } from "../tokens";
|
|
8
9
|
import { useScheme } from "../useScheme";
|
|
10
|
+
import { Input } from "../Input";
|
|
11
|
+
import { Textarea } from "../Textarea";
|
|
9
12
|
import { CardButton } from "./card-button";
|
|
10
13
|
import { chatTextSize, MONO_FONT } from "./chat-text";
|
|
11
14
|
|
|
15
|
+
export type DraftFieldRole = "to" | "cc" | "bcc" | "subject" | "body";
|
|
16
|
+
export type DraftField = { key: string; role: DraftFieldRole; value: string };
|
|
17
|
+
export type DraftEdit = { key: string; value: string };
|
|
18
|
+
|
|
19
|
+
export const DRAFT_ROLE_LABELS: Record<DraftFieldRole, string> = {
|
|
20
|
+
to: "To",
|
|
21
|
+
cc: "Cc",
|
|
22
|
+
bcc: "Bcc",
|
|
23
|
+
subject: "Subject",
|
|
24
|
+
body: "Message",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** The fields whose value differs from the original. `read` returns the edited value, or undefined for no edit. */
|
|
28
|
+
export function changedDraftEdits(
|
|
29
|
+
fields: readonly DraftField[],
|
|
30
|
+
read: (key: string) => string | undefined,
|
|
31
|
+
): DraftEdit[] {
|
|
32
|
+
return fields.flatMap((field) => {
|
|
33
|
+
const value = read(field.key);
|
|
34
|
+
return value === undefined || value === field.value ? [] : [{ key: field.key, value }];
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
12
38
|
/** What the reader chose. `edit` is offered by `ToolDraftCard` only. */
|
|
13
39
|
export type ToolDecision = "approve" | "deny" | "edit";
|
|
14
40
|
|
|
@@ -18,13 +44,25 @@ export type ToolApprovalCardProps = {
|
|
|
18
44
|
/** The arguments, as a short string or an object. Shown cut to `ARGS_MAX_CHARS`. */
|
|
19
45
|
args?: string | Record<string, unknown>;
|
|
20
46
|
/** Called with the choice. */
|
|
21
|
-
onDecide?: (decision: ToolDecision) => void;
|
|
47
|
+
onDecide?: (decision: ToolDecision, edits?: DraftEdit[]) => void;
|
|
22
48
|
/** True while the app sends the decision: the buttons are off and the card says so. */
|
|
23
49
|
pending?: boolean;
|
|
24
50
|
/** Turns the buttons off without the pending state. */
|
|
25
51
|
disabled?: boolean;
|
|
26
52
|
approveLabel?: string;
|
|
27
53
|
denyLabel?: string;
|
|
54
|
+
/** The tool server. The meta line reads `via {server}`, else `built-in tool`. */
|
|
55
|
+
server?: string;
|
|
56
|
+
/** Appends "queued while you were away" to the meta line. */
|
|
57
|
+
routineMode?: boolean;
|
|
58
|
+
/** Expiry text the app has formatted. Appends `Expires {expiresLabel}` to the meta line. */
|
|
59
|
+
expiresLabel?: string;
|
|
60
|
+
/** Shows a `Target: {target}` line. */
|
|
61
|
+
target?: string;
|
|
62
|
+
/** Replaces the default meta line. */
|
|
63
|
+
meta?: ReactNode;
|
|
64
|
+
/** Extra content under the meta line (above the target and args). */
|
|
65
|
+
chrome?: ReactNode;
|
|
28
66
|
className?: string;
|
|
29
67
|
/** Default `tool-approval-card`. The buttons add `-approve`, `-deny` and `-edit`. */
|
|
30
68
|
testID?: string;
|
|
@@ -50,7 +88,14 @@ export function summarizeArgs(args: string | Record<string, unknown> | undefined
|
|
|
50
88
|
return text.length > max ? `${text.slice(0, Math.max(0, max - 1))}…` : text;
|
|
51
89
|
}
|
|
52
90
|
|
|
53
|
-
type BaseProps = ToolApprovalCardProps & {
|
|
91
|
+
type BaseProps = ToolApprovalCardProps & {
|
|
92
|
+
allowEdit?: boolean;
|
|
93
|
+
editLabel?: string;
|
|
94
|
+
heading: string;
|
|
95
|
+
/** Text between the source and the expiry in the default meta line. */
|
|
96
|
+
metaNote?: string;
|
|
97
|
+
fields?: readonly DraftField[];
|
|
98
|
+
};
|
|
54
99
|
|
|
55
100
|
function ToolCardBase({
|
|
56
101
|
toolName,
|
|
@@ -63,6 +108,14 @@ function ToolCardBase({
|
|
|
63
108
|
editLabel = "Edit",
|
|
64
109
|
allowEdit = false,
|
|
65
110
|
heading,
|
|
111
|
+
metaNote,
|
|
112
|
+
fields,
|
|
113
|
+
server,
|
|
114
|
+
routineMode = false,
|
|
115
|
+
expiresLabel,
|
|
116
|
+
target,
|
|
117
|
+
meta,
|
|
118
|
+
chrome,
|
|
66
119
|
className,
|
|
67
120
|
testID = "tool-approval-card",
|
|
68
121
|
}: BaseProps) {
|
|
@@ -73,6 +126,23 @@ function ToolCardBase({
|
|
|
73
126
|
const caption = chatTextSize("caption", largerText);
|
|
74
127
|
const off = pending || disabled;
|
|
75
128
|
const summary = summarizeArgs(args);
|
|
129
|
+
const [edits, setEdits] = useState<Record<string, string> | null>(null);
|
|
130
|
+
const hasFields = fields !== undefined;
|
|
131
|
+
const headerFields = fields?.filter((field) => field.role !== "body") ?? [];
|
|
132
|
+
const bodyField = fields?.find((field) => field.role === "body");
|
|
133
|
+
const metaText = [
|
|
134
|
+
server ? `via ${server}` : "built-in tool",
|
|
135
|
+
...(metaNote ? [metaNote] : []),
|
|
136
|
+
].join(" · ") + (routineMode ? " · queued while you were away" : "") + (expiresLabel ? ` · Expires ${expiresLabel}` : "");
|
|
137
|
+
const showMeta = meta !== undefined || server !== undefined || routineMode || expiresLabel !== undefined || metaNote !== undefined;
|
|
138
|
+
const onEdit = () => {
|
|
139
|
+
if (!fields) return onDecide?.("edit");
|
|
140
|
+
setEdits(edits ? null : Object.fromEntries(fields.map((field) => [field.key, field.value])));
|
|
141
|
+
};
|
|
142
|
+
const onApprove = () => {
|
|
143
|
+
if (fields && edits) onDecide?.("approve", changedDraftEdits(fields, (key) => edits[key]));
|
|
144
|
+
else onDecide?.("approve");
|
|
145
|
+
};
|
|
76
146
|
return (
|
|
77
147
|
<View
|
|
78
148
|
testID={testID}
|
|
@@ -93,6 +163,14 @@ function ToolCardBase({
|
|
|
93
163
|
{pending ? "Sending decision…" : heading}
|
|
94
164
|
</Text>
|
|
95
165
|
</View>
|
|
166
|
+
{showMeta ? (
|
|
167
|
+
meta ?? (
|
|
168
|
+
<Text testID={`${testID}-meta`} className={caption} style={[sizeStyle(caption), textStyle("muted", scheme)]}>
|
|
169
|
+
{metaText}
|
|
170
|
+
</Text>
|
|
171
|
+
)
|
|
172
|
+
) : null}
|
|
173
|
+
{chrome}
|
|
96
174
|
<Text
|
|
97
175
|
testID={`${testID}-name`}
|
|
98
176
|
numberOfLines={2}
|
|
@@ -101,7 +179,58 @@ function ToolCardBase({
|
|
|
101
179
|
>
|
|
102
180
|
{toolName}
|
|
103
181
|
</Text>
|
|
104
|
-
{
|
|
182
|
+
{target ? (
|
|
183
|
+
<Text testID={`${testID}-target`} className={small} style={[sizeStyle(small), textStyle("muted", scheme)]}>
|
|
184
|
+
{`Target: ${target}`}
|
|
185
|
+
</Text>
|
|
186
|
+
) : null}
|
|
187
|
+
{hasFields ? (
|
|
188
|
+
edits ? (
|
|
189
|
+
<View testID={`${testID}-editor`} className="gap-2">
|
|
190
|
+
{fields.map((field) => (
|
|
191
|
+
<View key={field.key} className="gap-1">
|
|
192
|
+
<Text className={caption} style={[sizeStyle(caption), textStyle("muted", scheme)]}>
|
|
193
|
+
{DRAFT_ROLE_LABELS[field.role]}
|
|
194
|
+
</Text>
|
|
195
|
+
{field.role === "body" ? (
|
|
196
|
+
<Textarea
|
|
197
|
+
testID={`${testID}-field-${field.key}`}
|
|
198
|
+
value={edits[field.key] ?? ""}
|
|
199
|
+
onChangeText={(value: string) => setEdits({ ...edits, [field.key]: value })}
|
|
200
|
+
numberOfLines={8}
|
|
201
|
+
accessibilityLabel={DRAFT_ROLE_LABELS[field.role]}
|
|
202
|
+
/>
|
|
203
|
+
) : (
|
|
204
|
+
<Input
|
|
205
|
+
testID={`${testID}-field-${field.key}`}
|
|
206
|
+
value={edits[field.key] ?? ""}
|
|
207
|
+
onChangeText={(value: string) => setEdits({ ...edits, [field.key]: value })}
|
|
208
|
+
accessibilityLabel={DRAFT_ROLE_LABELS[field.role]}
|
|
209
|
+
/>
|
|
210
|
+
)}
|
|
211
|
+
</View>
|
|
212
|
+
))}
|
|
213
|
+
</View>
|
|
214
|
+
) : (
|
|
215
|
+
<View testID={`${testID}-preview`} className="gap-2">
|
|
216
|
+
{headerFields.map((field) => (
|
|
217
|
+
<View key={field.key} className="flex-row gap-2">
|
|
218
|
+
<Text className={cn(caption, "w-16")} style={[sizeStyle(caption), textStyle("muted", scheme)]}>
|
|
219
|
+
{DRAFT_ROLE_LABELS[field.role]}
|
|
220
|
+
</Text>
|
|
221
|
+
<Text className={cn(small, "min-w-0 flex-1")} style={[sizeStyle(small), textStyle("foreground", scheme)]}>
|
|
222
|
+
{field.value}
|
|
223
|
+
</Text>
|
|
224
|
+
</View>
|
|
225
|
+
))}
|
|
226
|
+
{bodyField ? (
|
|
227
|
+
<Text numberOfLines={12} className={body} style={[sizeStyle(body), textStyle("foreground", scheme)]}>
|
|
228
|
+
{bodyField.value}
|
|
229
|
+
</Text>
|
|
230
|
+
) : null}
|
|
231
|
+
</View>
|
|
232
|
+
)
|
|
233
|
+
) : summary ? (
|
|
105
234
|
<Text
|
|
106
235
|
testID={`${testID}-args`}
|
|
107
236
|
numberOfLines={3}
|
|
@@ -123,11 +252,11 @@ function ToolCardBase({
|
|
|
123
252
|
{allowEdit ? (
|
|
124
253
|
<CardButton
|
|
125
254
|
testID={`${testID}-edit`}
|
|
126
|
-
label={editLabel}
|
|
127
|
-
accessibilityLabel={`${editLabel} ${toolName}`}
|
|
255
|
+
label={edits ? "Cancel edit" : editLabel}
|
|
256
|
+
accessibilityLabel={`${edits ? "Cancel edit" : editLabel} ${toolName}`}
|
|
128
257
|
variant="secondary"
|
|
129
258
|
disabled={off}
|
|
130
|
-
onPress={
|
|
259
|
+
onPress={onEdit}
|
|
131
260
|
/>
|
|
132
261
|
) : null}
|
|
133
262
|
<CardButton
|
|
@@ -136,7 +265,7 @@ function ToolCardBase({
|
|
|
136
265
|
accessibilityLabel={`${approveLabel} ${toolName}`}
|
|
137
266
|
variant="primary"
|
|
138
267
|
disabled={off}
|
|
139
|
-
onPress={
|
|
268
|
+
onPress={onApprove}
|
|
140
269
|
/>
|
|
141
270
|
</View>
|
|
142
271
|
</View>
|
|
@@ -154,9 +283,11 @@ export function ToolApprovalCard(props: ToolApprovalCardProps) {
|
|
|
154
283
|
|
|
155
284
|
export type ToolDraftCardProps = ToolApprovalCardProps & {
|
|
156
285
|
editLabel?: string;
|
|
286
|
+
/** The draft fields. Shows them as a preview and, under Edit, as inputs. Without them Edit reports `"edit"`. */
|
|
287
|
+
fields?: readonly DraftField[];
|
|
157
288
|
};
|
|
158
289
|
|
|
159
290
|
/** As `ToolApprovalCard`, plus an Edit button that reports `"edit"`, for a draft the reader may change first. */
|
|
160
291
|
export function ToolDraftCard({ testID = "tool-draft-card", ...props }: ToolDraftCardProps) {
|
|
161
|
-
return <ToolCardBase {...props} testID={testID} allowEdit heading="Draft for review" />;
|
|
292
|
+
return <ToolCardBase {...props} testID={testID} allowEdit heading="Draft for review" metaNote="Nothing is sent until you press Send" />;
|
|
162
293
|
}
|
|
@@ -39,10 +39,17 @@ import {
|
|
|
39
39
|
SourcesTrigger,
|
|
40
40
|
Suggestion,
|
|
41
41
|
Suggestions,
|
|
42
|
+
SubagentResultCard,
|
|
43
|
+
SubagentRunCard,
|
|
44
|
+
ToolApprovalCard,
|
|
45
|
+
ToolDraftCard,
|
|
42
46
|
formatElapsed,
|
|
43
47
|
useStreamingDisclosure,
|
|
44
48
|
type AttachmentData,
|
|
45
49
|
type ChatStatus,
|
|
50
|
+
type DraftEdit,
|
|
51
|
+
type DraftField,
|
|
52
|
+
type SubagentStatus,
|
|
46
53
|
} from "../ai";
|
|
47
54
|
|
|
48
55
|
const SUGGESTIONS = [
|
|
@@ -73,6 +80,68 @@ const STREAMING_REASONING =
|
|
|
73
80
|
* reasoning and trace, reply, code, sources and actions), a second user
|
|
74
81
|
* message, and an assistant message that is still working while `streaming`.
|
|
75
82
|
*/
|
|
83
|
+
const DRAFT_FIELDS: DraftField[] = [
|
|
84
|
+
{ key: "to", role: "to", value: "team@example.com" },
|
|
85
|
+
{ key: "subject", role: "subject", value: "Weekly plan" },
|
|
86
|
+
{ key: "body", role: "body", value: "Here is the plan for next week." },
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const RUN_STATUSES: SubagentStatus[] = ["queued", "running", "done", "failed", "cancelled", "timed_out"];
|
|
90
|
+
|
|
91
|
+
/** Tool approval, draft and subagent cards, with testIDs for E2E. */
|
|
92
|
+
export function AiCardSamples() {
|
|
93
|
+
const [decision, setDecision] = useState("none");
|
|
94
|
+
const [cancelled, setCancelled] = useState(false);
|
|
95
|
+
const decide = (name: string) => (choice: string, edits?: DraftEdit[]) =>
|
|
96
|
+
setDecision(`${name}:${choice}${edits ? `:${JSON.stringify(edits)}` : ""}`);
|
|
97
|
+
return (
|
|
98
|
+
<>
|
|
99
|
+
<ResponseText>{`Last decision: ${decision}`}</ResponseText>
|
|
100
|
+
<ToolApprovalCard
|
|
101
|
+
testID="gallery-approval"
|
|
102
|
+
toolName="create_task"
|
|
103
|
+
args={{ title: "Review plan" }}
|
|
104
|
+
server="tasks"
|
|
105
|
+
routineMode
|
|
106
|
+
expiresLabel="tomorrow"
|
|
107
|
+
target="Project board"
|
|
108
|
+
onDecide={decide("approval")}
|
|
109
|
+
/>
|
|
110
|
+
<ToolDraftCard
|
|
111
|
+
testID="gallery-draft"
|
|
112
|
+
toolName="send_message"
|
|
113
|
+
fields={DRAFT_FIELDS}
|
|
114
|
+
server="mail"
|
|
115
|
+
expiresLabel="in 2 hours"
|
|
116
|
+
approveLabel="Send"
|
|
117
|
+
denyLabel="Discard"
|
|
118
|
+
onDecide={decide("draft")}
|
|
119
|
+
/>
|
|
120
|
+
{RUN_STATUSES.map((status) => (
|
|
121
|
+
<SubagentRunCard
|
|
122
|
+
key={status}
|
|
123
|
+
testID={`gallery-run-${status}`}
|
|
124
|
+
title="Research"
|
|
125
|
+
status={status}
|
|
126
|
+
elapsedSeconds={65}
|
|
127
|
+
statusLine={status === "running" ? "Step 2 of 4" : undefined}
|
|
128
|
+
error={status === "failed" ? "The source could not be reached." : undefined}
|
|
129
|
+
onCancel={() => setCancelled(true)}
|
|
130
|
+
/>
|
|
131
|
+
))}
|
|
132
|
+
<ResponseText>{`Cancelled: ${cancelled ? "yes" : "no"}`}</ResponseText>
|
|
133
|
+
<SubagentResultCard
|
|
134
|
+
testID="gallery-result"
|
|
135
|
+
title="Research"
|
|
136
|
+
summary="Three sources found."
|
|
137
|
+
approvalPending
|
|
138
|
+
approvalPendingIn="Planner"
|
|
139
|
+
handoffs={[{ toolName: "create_task", server: "tasks", argsSummary: '{"title":"Review plan"}' }]}
|
|
140
|
+
/>
|
|
141
|
+
</>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
76
145
|
export function AiChatSamples({ streaming = true }: { streaming?: boolean }) {
|
|
77
146
|
const [feedback, setFeedback] = useState<"like" | "dislike" | null>(null);
|
|
78
147
|
const searchFold = useStreamingDisclosure(streaming);
|
|
@@ -164,6 +233,7 @@ export function AiChatSamples({ streaming = true }: { streaming?: boolean }) {
|
|
|
164
233
|
)}
|
|
165
234
|
</MessageContent>
|
|
166
235
|
</Message>
|
|
236
|
+
<AiCardSamples />
|
|
167
237
|
</>
|
|
168
238
|
);
|
|
169
239
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import * as SelectPrimitive from "@rn-primitives/select";
|
|
2
|
+
import * as React from "react";
|
|
3
|
+
import { Modal, Platform, View } from "react-native";
|
|
4
|
+
import { FullWindowOverlay } from "react-native-screens";
|
|
5
|
+
import { useReduceMotion } from "../useReduceMotion";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where `SelectContent` draws its options.
|
|
9
|
+
*
|
|
10
|
+
* - Android: a transparent RN `Modal` with a full-size `box-none` host, as
|
|
11
|
+
* `DropdownMenuContent` and `OverlayModal` use. The Modal is its own window,
|
|
12
|
+
* so options sit above an absolute tab bar or glass layer. `SelectContent`
|
|
13
|
+
* renders this host without a Portal, because a Modal inside a portal mounts
|
|
14
|
+
* empty. With no Portal there is no `open` gate from the primitive, so the
|
|
15
|
+
* host mounts the Modal only while the Select is open. Android back closes it.
|
|
16
|
+
* - iOS: `FullWindowOverlay`, which sits above native navigation chrome.
|
|
17
|
+
* - Web: no wrapper. Radix owns the layering.
|
|
18
|
+
*
|
|
19
|
+
* `Platform.OS` is read at render, not at import, so a test can switch it.
|
|
20
|
+
*/
|
|
21
|
+
export function SelectHost({ children }: { children: React.ReactNode }) {
|
|
22
|
+
const { open, onOpenChange, setTriggerPosition, setContentLayout } =
|
|
23
|
+
SelectPrimitive.useRootContext();
|
|
24
|
+
const reduceMotion = useReduceMotion();
|
|
25
|
+
|
|
26
|
+
if (Platform.OS === "ios") {
|
|
27
|
+
return <FullWindowOverlay>{children}</FullWindowOverlay>;
|
|
28
|
+
}
|
|
29
|
+
if (Platform.OS !== "android") {
|
|
30
|
+
return <>{children}</>;
|
|
31
|
+
}
|
|
32
|
+
if (!open) {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const close = () => {
|
|
37
|
+
setTriggerPosition(null);
|
|
38
|
+
setContentLayout(null);
|
|
39
|
+
onOpenChange(false);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
<Modal
|
|
44
|
+
visible
|
|
45
|
+
transparent
|
|
46
|
+
statusBarTranslucent
|
|
47
|
+
animationType={reduceMotion ? "none" : "fade"}
|
|
48
|
+
onRequestClose={close}
|
|
49
|
+
>
|
|
50
|
+
<View style={{ flex: 1 }} pointerEvents="box-none" testID="SelectContent-host">
|
|
51
|
+
{children}
|
|
52
|
+
</View>
|
|
53
|
+
</Modal>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -5,21 +5,20 @@ import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react-n
|
|
|
5
5
|
import * as React from "react";
|
|
6
6
|
import { Platform, ScrollView, StyleSheet, Text, View } from "react-native";
|
|
7
7
|
import { FadeIn, FadeOut } from "react-native-reanimated";
|
|
8
|
-
import { FullWindowOverlay as RNFullWindowOverlay } from "react-native-screens";
|
|
9
8
|
import { NativeOnlyAnimatedView } from "./native-only-animated-view";
|
|
9
|
+
import { SelectHost } from "./select-host";
|
|
10
10
|
import { TextClassContext } from "./text";
|
|
11
11
|
import { LiquidGlass } from "../LiquidGlass";
|
|
12
12
|
import { cn } from "../cn";
|
|
13
13
|
import { textStyle, typeStyle } from "../controlStyles";
|
|
14
14
|
import { tokens } from "../tokens";
|
|
15
15
|
import { useScheme } from "../useScheme";
|
|
16
|
+
import { useLargerText } from "../theme";
|
|
16
17
|
|
|
17
18
|
type Option = SelectPrimitive.Option;
|
|
18
19
|
|
|
19
20
|
const Select = SelectPrimitive.Root;
|
|
20
21
|
const SelectGroup = SelectPrimitive.Group;
|
|
21
|
-
const FullWindowOverlay =
|
|
22
|
-
Platform.OS === "ios" ? RNFullWindowOverlay : React.Fragment;
|
|
23
22
|
|
|
24
23
|
type SelectValueProps = React.ComponentPropsWithoutRef<
|
|
25
24
|
typeof SelectPrimitive.Value
|
|
@@ -85,48 +84,60 @@ const SelectContent = React.forwardRef<
|
|
|
85
84
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
|
86
85
|
SelectContentProps
|
|
87
86
|
>(({ className, children, position = "popper", portalHost, ...props }, ref) => {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
87
|
+
const overlayFill = Platform.select({ native: StyleSheet.absoluteFill });
|
|
88
|
+
const content = (
|
|
89
|
+
<SelectPrimitive.Overlay style={overlayFill}>
|
|
90
|
+
<TextClassContext.Provider value="text-popover-foreground">
|
|
91
|
+
<NativeOnlyAnimatedView
|
|
92
|
+
entering={FadeIn}
|
|
93
|
+
exiting={FadeOut}
|
|
94
|
+
className="z-50"
|
|
95
|
+
// Content is absolutely positioned, so a bare wrapper has zero height
|
|
96
|
+
// and UI tests drop its children as invisible. Fill the overlay and
|
|
97
|
+
// let touches fall through to it.
|
|
98
|
+
style={overlayFill}
|
|
99
|
+
pointerEvents={Platform.select({ native: "box-none" })}
|
|
93
100
|
>
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
101
|
+
<SelectPrimitive.Content
|
|
102
|
+
ref={ref}
|
|
103
|
+
position={position}
|
|
104
|
+
className={cn(
|
|
105
|
+
"z-50 min-w-[8rem] rounded-lg",
|
|
106
|
+
Platform.select({
|
|
107
|
+
native: "p-1",
|
|
108
|
+
web: "max-h-52 overflow-hidden",
|
|
109
|
+
}),
|
|
110
|
+
className,
|
|
111
|
+
)}
|
|
112
|
+
{...props}
|
|
113
|
+
>
|
|
114
|
+
<LiquidGlass
|
|
115
|
+
pointerEvents="none"
|
|
116
|
+
elevated={false}
|
|
117
|
+
className="absolute inset-0 rounded-lg"
|
|
118
|
+
/>
|
|
119
|
+
<SelectScrollUpButton />
|
|
120
|
+
<SelectPrimitive.Viewport
|
|
121
|
+
className={cn("p-1", position === "popper" && "w-full")}
|
|
99
122
|
>
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
<SelectScrollUpButton />
|
|
119
|
-
<SelectPrimitive.Viewport
|
|
120
|
-
className={cn("p-1", position === "popper" && "w-full")}
|
|
121
|
-
>
|
|
122
|
-
{children}
|
|
123
|
-
</SelectPrimitive.Viewport>
|
|
124
|
-
<SelectScrollDownButton />
|
|
125
|
-
</SelectPrimitive.Content>
|
|
126
|
-
</NativeOnlyAnimatedView>
|
|
127
|
-
</TextClassContext.Provider>
|
|
128
|
-
</SelectPrimitive.Overlay>
|
|
129
|
-
</FullWindowOverlay>
|
|
123
|
+
{children}
|
|
124
|
+
</SelectPrimitive.Viewport>
|
|
125
|
+
<SelectScrollDownButton />
|
|
126
|
+
</SelectPrimitive.Content>
|
|
127
|
+
</NativeOnlyAnimatedView>
|
|
128
|
+
</TextClassContext.Provider>
|
|
129
|
+
</SelectPrimitive.Overlay>
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// Android: no Portal. A Modal inside a portal mounts empty, so `SelectHost`
|
|
133
|
+
// renders the Modal directly and gates it on `open`. iOS and web keep the
|
|
134
|
+
// Portal; `SelectHost` adds the full-window layer on iOS.
|
|
135
|
+
if (Platform.OS === "android") {
|
|
136
|
+
return <SelectHost>{content}</SelectHost>;
|
|
137
|
+
}
|
|
138
|
+
return (
|
|
139
|
+
<SelectPrimitive.Portal hostName={portalHost}>
|
|
140
|
+
<SelectHost>{content}</SelectHost>
|
|
130
141
|
</SelectPrimitive.Portal>
|
|
131
142
|
);
|
|
132
143
|
});
|
|
@@ -166,12 +177,15 @@ const SelectItem = React.forwardRef<
|
|
|
166
177
|
SelectItemProps
|
|
167
178
|
>(({ className, disabled, ...props }, ref) => {
|
|
168
179
|
const scheme = useScheme();
|
|
180
|
+
const largerText = useLargerText();
|
|
169
181
|
return (
|
|
170
182
|
<SelectPrimitive.Item
|
|
171
183
|
ref={ref}
|
|
172
184
|
disabled={disabled}
|
|
173
185
|
className={cn(
|
|
174
|
-
|
|
186
|
+
// 44 high (56 with larger text) so a row is a full touch target.
|
|
187
|
+
"relative min-h-[44px] flex-row items-center gap-2 rounded-sm py-3 pl-2 pr-8 active:opacity-70",
|
|
188
|
+
largerText && "min-h-[56px]",
|
|
175
189
|
disabled && "opacity-50",
|
|
176
190
|
className,
|
|
177
191
|
)}
|