@vvedantb/native-ui 0.13.0 → 0.17.1

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.
Files changed (111) hide show
  1. package/README.md +42 -21
  2. package/package.json +36 -3
  3. package/src/BottomSheetContainer.tsx +2 -2
  4. package/src/BulkActionBar.tsx +240 -105
  5. package/src/Button.tsx +11 -7
  6. package/src/ConfirmDialog.tsx +25 -35
  7. package/src/Dialog.tsx +47 -35
  8. package/src/DividedList.tsx +11 -7
  9. package/src/DropdownMenu.tsx +22 -8
  10. package/src/FloatingSurface.tsx +5 -2
  11. package/src/FloatingTabBar.tsx +13 -22
  12. package/src/HeaderIconButton.tsx +4 -3
  13. package/src/Input.tsx +2 -2
  14. package/src/LoadingState.tsx +132 -0
  15. package/src/PageTitle.tsx +9 -2
  16. package/src/Pager.tsx +62 -0
  17. package/src/PasswordInput.tsx +1 -1
  18. package/src/PillTabs.tsx +144 -34
  19. package/src/Progress.tsx +9 -7
  20. package/src/Select.tsx +4 -4
  21. package/src/Sheet.tsx +29 -22
  22. package/src/SheetRow.tsx +27 -9
  23. package/src/Skeleton.tsx +91 -9
  24. package/src/SurfaceRow.tsx +111 -0
  25. package/src/SwipeableRow.tsx +43 -0
  26. package/src/Switch.tsx +6 -4
  27. package/src/Tabs.tsx +68 -13
  28. package/src/Toast.tsx +107 -65
  29. package/src/ai/activity-step-detail.tsx +1 -3
  30. package/src/ai/activity-step-label.ts +981 -0
  31. package/src/ai/activity-step-types.ts +107 -0
  32. package/src/ai/activity-tasks-utils.ts +199 -0
  33. package/src/ai/activity-timeline.ts +45 -0
  34. package/src/ai/attachments.tsx +3 -1
  35. package/src/ai/chat-text.ts +13 -7
  36. package/src/ai/chat-thread.tsx +34 -27
  37. package/src/ai/choice-question.tsx +154 -35
  38. package/src/ai/code-block.tsx +189 -49
  39. package/src/ai/collapsible.tsx +23 -13
  40. package/src/ai/conversation.tsx +394 -57
  41. package/src/ai/icon-button.tsx +41 -6
  42. package/src/ai/index.ts +55 -4
  43. package/src/ai/message-action-sheet.tsx +4 -1
  44. package/src/ai/message.tsx +60 -31
  45. package/src/ai/prompt-input-menu.tsx +197 -0
  46. package/src/ai/prompt-input.tsx +301 -75
  47. package/src/ai/shimmer.tsx +4 -6
  48. package/src/ai/subagent-cards.tsx +84 -14
  49. package/src/ai/tool-approval-card.tsx +139 -8
  50. package/src/ai/trace.tsx +54 -17
  51. package/src/ai/unified-diff.tsx +182 -0
  52. package/src/auth/GoogleSignInButton.tsx +6 -3
  53. package/src/auth/SignInScreen.tsx +11 -2
  54. package/src/brand/ProviderIcon.tsx +136 -0
  55. package/src/brand/index.ts +2 -0
  56. package/src/configureTheme.ts +39 -11
  57. package/src/controlStyles.ts +46 -30
  58. package/src/entries/gesture-handler.ts +4 -0
  59. package/src/entries/keyboard-controller.ts +4 -0
  60. package/src/entries/markdown.ts +1 -1
  61. package/src/entries/primitives.ts +24 -3
  62. package/src/entries/theme.ts +2 -1
  63. package/src/entries/video.ts +4 -0
  64. package/src/entries/webview.ts +6 -0
  65. package/src/examples/AiChatGallery.tsx +70 -0
  66. package/src/floating.ts +25 -4
  67. package/src/haptics.ts +30 -4
  68. package/src/highlight.ts +302 -1
  69. package/src/index.ts +27 -5
  70. package/src/loadingGrid.ts +75 -0
  71. package/src/markdown/Markdown.tsx +234 -32
  72. package/src/markdown/MarkdownTable.tsx +188 -0
  73. package/src/markdown/tableText.ts +58 -0
  74. package/src/marketing/UpgradeToProCard.tsx +4 -3
  75. package/src/media/ImageGallery.tsx +224 -0
  76. package/src/media/MediaCard.tsx +100 -0
  77. package/src/media/imageGrid.ts +51 -0
  78. package/src/motion.ts +33 -0
  79. package/src/pagerSwipe.ts +102 -0
  80. package/src/palette.ts +8 -1
  81. package/src/primitives/button.tsx +4 -1
  82. package/src/primitives/keyboard-controller.tsx +106 -0
  83. package/src/primitives/keyboard-parts.tsx +104 -0
  84. package/src/primitives/overlay.tsx +133 -45
  85. package/src/primitives/pager-gesture.tsx +131 -0
  86. package/src/primitives/sheet-drag.tsx +305 -0
  87. package/src/primitives/sheet-gesture.tsx +181 -0
  88. package/src/primitives/swipe-row-gesture.tsx +290 -0
  89. package/src/primitives/toast-drag-gesture.tsx +92 -0
  90. package/src/primitives/web-click-guard.ts +50 -0
  91. package/src/relativeTime.ts +94 -0
  92. package/src/router/RouterStackPageHeader.tsx +2 -2
  93. package/src/settings/SettingsEmptyState.tsx +20 -10
  94. package/src/settings/SettingsField.tsx +11 -5
  95. package/src/settings/SettingsSection.tsx +8 -5
  96. package/src/settings/SettingsToggleRow.tsx +55 -22
  97. package/src/settings/relaxedSizeStyle.ts +11 -0
  98. package/src/sheetDrag.ts +67 -0
  99. package/src/swipeRow.ts +169 -0
  100. package/src/tabBarRoutes.ts +18 -0
  101. package/src/tabIndicator.ts +71 -0
  102. package/src/tailwind-preset.cjs +13 -6
  103. package/src/tailwind-preset.d.cts +5 -0
  104. package/src/theme.tsx +6 -2
  105. package/src/toastDrag.ts +58 -0
  106. package/src/typography.ts +20 -3
  107. package/src/useScheme.ts +10 -1
  108. package/src/video/InlineVideo.tsx +91 -0
  109. package/src/webview/HtmlView.tsx +91 -0
  110. package/src/webview/HtmlView.web.tsx +21 -0
  111. package/src/webview/htmlView.ts +45 -0
@@ -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
- <IconRobot size={largerText ? 22 : 18} color={tokens.color.muted[scheme]} />
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
- <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>
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 & { allowEdit?: boolean; editLabel?: string; heading: string };
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
- {summary ? (
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={() => onDecide?.("edit")}
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={() => onDecide?.("approve")}
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
  }
package/src/ai/trace.tsx CHANGED
@@ -2,7 +2,7 @@ import { createContext, useContext, type ComponentType, type ReactNode } from "r
2
2
  import { Pressable, Text, View, type ViewProps } from "react-native";
3
3
  import type { IconProps } from "@tabler/icons-react-native";
4
4
  import { cn } from "../cn";
5
- import { sizeStyle, touchSlop } from "../controlStyles";
5
+ import { sizeStyle } from "../controlStyles";
6
6
  import { palette } from "../palette";
7
7
  import { useLargerText } from "../theme";
8
8
  import { tokens } from "../tokens";
@@ -40,9 +40,13 @@ function useTraceColor() {
40
40
  return useContext(TraceLineColorContext) ?? tokens.color.muted[scheme];
41
41
  }
42
42
 
43
- /** A column of trace lines (web `flex w-full flex-col gap-2`). */
43
+ /**
44
+ * A column of trace lines (web `flex w-full flex-col gap-2`). Each line is 28
45
+ * high (its `py-1` around the 20 text) with no gap, so the rows keep the web
46
+ * 28 pitch and each is its whole tap area, without slop over its neighbours.
47
+ */
44
48
  export function AgentTrace({ className, ...props }: ViewProps & { className?: string }) {
45
- return <View className={cn("w-full min-w-0 gap-2", className)} {...props} />;
49
+ return <View className={cn("w-full min-w-0", className)} {...props} />;
46
50
  }
47
51
 
48
52
  export type AgentTraceLineProps = ViewProps & {
@@ -50,13 +54,13 @@ export type AgentTraceLineProps = ViewProps & {
50
54
  className?: string;
51
55
  };
52
56
 
53
- /** One line: a row of icon, title and description. `pending` fades it to 60%, `error` turns it the danger colour. */
57
+ /** One line, 28 high (`py-1`): a row of icon, title and description. `pending` fades it to 60%, `error` turns it the danger colour. */
54
58
  export function AgentTraceLine({ status, className, style, ...props }: AgentTraceLineProps) {
55
59
  const scheme = useScheme();
56
60
  return (
57
61
  <TraceLineColorContext.Provider value={traceLineColor(status, scheme)}>
58
62
  <View
59
- className={cn("min-w-0 max-w-full shrink flex-row items-center gap-2", className)}
63
+ className={cn("min-w-0 max-w-full shrink flex-row items-center gap-2 py-1", className)}
60
64
  style={[status === "pending" ? { opacity: 0.6 } : null, style]}
61
65
  {...props}
62
66
  />
@@ -149,7 +153,17 @@ export type AgentTraceCollapsibleTriggerProps = CollapsibleTriggerProps & {
149
153
  trailing?: ReactNode;
150
154
  };
151
155
 
152
- /** The line as a toggle, with a chevron after it that turns down when open. */
156
+ /** No slop: the 28 high line is the whole target, as trace lines stack with no gap. */
157
+ const LINE_SLOP = { top: 0, bottom: 0, left: 0, right: 0 } as const;
158
+
159
+ /** The chevron's slop: 4 back toward the line, 14 on past it (a 34 wide target), none above or below. */
160
+ const CHEVRON_SLOP = { top: 0, bottom: 0, left: 4, right: 14 } as const;
161
+
162
+ /**
163
+ * The line as a toggle, with a chevron after it that turns down when open.
164
+ * The trigger stretches to the line's 28 height and takes no slop above or
165
+ * below, so a tap under one line never opens the next.
166
+ */
153
167
  export function AgentTraceCollapsibleTrigger({
154
168
  children,
155
169
  trailing,
@@ -166,6 +180,7 @@ export function AgentTraceCollapsibleTrigger({
166
180
  if (trailing === undefined) {
167
181
  return (
168
182
  <CollapsibleTrigger
183
+ hitSlop={LINE_SLOP}
169
184
  className={cn("max-w-full flex-row items-center gap-1 self-start", className)}
170
185
  {...props}
171
186
  >
@@ -176,7 +191,7 @@ export function AgentTraceCollapsibleTrigger({
176
191
  }
177
192
  return (
178
193
  <View className={cn("max-w-full flex-row items-center gap-2 self-start", className)}>
179
- <CollapsibleTrigger className="min-w-0 shrink flex-row items-center" {...props}>
194
+ <CollapsibleTrigger hitSlop={LINE_SLOP} className="min-w-0 shrink flex-row items-center self-stretch" {...props}>
180
195
  {children}
181
196
  </CollapsibleTrigger>
182
197
  {trailing}
@@ -184,8 +199,9 @@ export function AgentTraceCollapsibleTrigger({
184
199
  <Pressable
185
200
  accessible={false}
186
201
  importantForAccessibility="no-hide-descendants"
187
- hitSlop={touchSlop(iconSize)}
202
+ hitSlop={CHEVRON_SLOP}
188
203
  onPress={() => setOpen(!open)}
204
+ className="self-stretch justify-center"
189
205
  >
190
206
  {chevron}
191
207
  </Pressable>
@@ -210,7 +226,7 @@ export function AgentTraceCollapsibleContent({
210
226
  return (
211
227
  <CollapsibleContent>
212
228
  <View
213
- className={cn("mb-1 mt-2 min-w-0 pl-4 pr-1", className)}
229
+ className={cn("mb-2 mt-1 min-w-0 pl-4 pr-1", className)}
214
230
  style={[
215
231
  {
216
232
  marginLeft: iconSize / 2 - 1,
@@ -234,31 +250,52 @@ export function AgentTraceCollapsibleContent({
234
250
  }
235
251
 
236
252
  export type AgentTraceFoldProps = Omit<CollapsibleProps, "children"> & {
237
- /** How long the turn ran, in seconds. The title reads "Worked for 1m 5s". */
238
- seconds: number;
239
253
  /** The settled turn's steps, shown in an `AgentTrace` when open. */
240
254
  children: ReactNode;
241
- };
255
+ } & (
256
+ | {
257
+ /** How long the turn ran, in seconds. The title reads "Worked for 1m 5s" (`formatElapsed`). */
258
+ seconds: number;
259
+ duration?: never;
260
+ }
261
+ | {
262
+ /**
263
+ * The duration already formatted, as the web `ActivityTasks` `duration`:
264
+ * the title reads "Worked for " and this. For an app's own formatter.
265
+ */
266
+ duration: string;
267
+ seconds?: never;
268
+ }
269
+ );
242
270
 
243
271
  /**
244
272
  * A settled turn folded behind one line, as the web `ActivityTasks` in its
245
273
  * folded (Codex-style) mode: "Worked for …" over a hairline
246
- * (`border-b border-border pb-1.5`), closed by default, and the steps in an
274
+ * 6 below the text (web `border-b border-border pb-1.5`: the line's own 4 and
275
+ * 2 more), closed by default, and the steps in an
247
276
  * `AgentTrace` 8 below when open.
248
277
  */
249
- export function AgentTraceFold({ seconds, children, defaultOpen = false, className, ...props }: AgentTraceFoldProps) {
278
+ export function AgentTraceFold({
279
+ seconds,
280
+ duration,
281
+ children,
282
+ defaultOpen = false,
283
+ className,
284
+ ...props
285
+ }: AgentTraceFoldProps) {
250
286
  const scheme = useScheme();
287
+ const elapsed = duration ?? formatElapsed(seconds ?? 0);
251
288
  return (
252
289
  <Collapsible className={cn("w-full min-w-0", className)} defaultOpen={defaultOpen} {...props}>
253
- <View style={{ borderBottomWidth: 1, borderBottomColor: tokens.color.border[scheme], paddingBottom: 6 }}>
290
+ <View style={{ borderBottomWidth: 1, borderBottomColor: tokens.color.border[scheme], paddingBottom: 2 }}>
254
291
  <AgentTraceCollapsibleTrigger>
255
292
  <AgentTraceLine>
256
- <AgentTraceTitle>{`Worked for ${formatElapsed(seconds)}`}</AgentTraceTitle>
293
+ <AgentTraceTitle>{`Worked for ${elapsed}`}</AgentTraceTitle>
257
294
  </AgentTraceLine>
258
295
  </AgentTraceCollapsibleTrigger>
259
296
  </View>
260
297
  <CollapsibleContent>
261
- <AgentTrace className="mt-2">{children}</AgentTrace>
298
+ <AgentTrace className="mt-1">{children}</AgentTrace>
262
299
  </CollapsibleContent>
263
300
  </Collapsible>
264
301
  );