@vvedantb/native-ui 0.13.0 → 0.17.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.
Files changed (108) hide show
  1. package/README.md +40 -19
  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 +19 -13
  40. package/src/ai/conversation.tsx +390 -57
  41. package/src/ai/icon-button.tsx +41 -6
  42. package/src/ai/index.ts +38 -1
  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/trace.tsx +54 -17
  49. package/src/ai/unified-diff.tsx +182 -0
  50. package/src/auth/GoogleSignInButton.tsx +6 -3
  51. package/src/auth/SignInScreen.tsx +11 -2
  52. package/src/brand/ProviderIcon.tsx +136 -0
  53. package/src/brand/index.ts +2 -0
  54. package/src/configureTheme.ts +39 -11
  55. package/src/controlStyles.ts +46 -30
  56. package/src/entries/gesture-handler.ts +4 -0
  57. package/src/entries/keyboard-controller.ts +4 -0
  58. package/src/entries/markdown.ts +1 -1
  59. package/src/entries/primitives.ts +24 -3
  60. package/src/entries/theme.ts +2 -1
  61. package/src/entries/video.ts +4 -0
  62. package/src/entries/webview.ts +6 -0
  63. package/src/floating.ts +25 -4
  64. package/src/haptics.ts +30 -4
  65. package/src/highlight.ts +302 -1
  66. package/src/index.ts +27 -5
  67. package/src/loadingGrid.ts +75 -0
  68. package/src/markdown/Markdown.tsx +234 -32
  69. package/src/markdown/MarkdownTable.tsx +188 -0
  70. package/src/markdown/tableText.ts +58 -0
  71. package/src/marketing/UpgradeToProCard.tsx +4 -3
  72. package/src/media/ImageGallery.tsx +224 -0
  73. package/src/media/MediaCard.tsx +100 -0
  74. package/src/media/imageGrid.ts +51 -0
  75. package/src/motion.ts +33 -0
  76. package/src/pagerSwipe.ts +102 -0
  77. package/src/palette.ts +8 -1
  78. package/src/primitives/button.tsx +4 -1
  79. package/src/primitives/keyboard-controller.tsx +106 -0
  80. package/src/primitives/keyboard-parts.tsx +104 -0
  81. package/src/primitives/overlay.tsx +133 -45
  82. package/src/primitives/pager-gesture.tsx +131 -0
  83. package/src/primitives/sheet-drag.tsx +305 -0
  84. package/src/primitives/sheet-gesture.tsx +181 -0
  85. package/src/primitives/swipe-row-gesture.tsx +290 -0
  86. package/src/primitives/toast-drag-gesture.tsx +92 -0
  87. package/src/primitives/web-click-guard.ts +50 -0
  88. package/src/relativeTime.ts +94 -0
  89. package/src/router/RouterStackPageHeader.tsx +2 -2
  90. package/src/settings/SettingsEmptyState.tsx +20 -10
  91. package/src/settings/SettingsField.tsx +11 -5
  92. package/src/settings/SettingsSection.tsx +8 -5
  93. package/src/settings/SettingsToggleRow.tsx +55 -22
  94. package/src/settings/relaxedSizeStyle.ts +11 -0
  95. package/src/sheetDrag.ts +67 -0
  96. package/src/swipeRow.ts +169 -0
  97. package/src/tabBarRoutes.ts +18 -0
  98. package/src/tabIndicator.ts +71 -0
  99. package/src/tailwind-preset.cjs +13 -6
  100. package/src/tailwind-preset.d.cts +5 -0
  101. package/src/theme.tsx +6 -2
  102. package/src/toastDrag.ts +58 -0
  103. package/src/typography.ts +20 -3
  104. package/src/useScheme.ts +10 -1
  105. package/src/video/InlineVideo.tsx +91 -0
  106. package/src/webview/HtmlView.tsx +91 -0
  107. package/src/webview/HtmlView.web.tsx +21 -0
  108. package/src/webview/htmlView.ts +45 -0
@@ -10,17 +10,44 @@ import { useScheme } from "../useScheme";
10
10
  /** The web `icon-sm` button: 32 square with a 16 icon. */
11
11
  export const CHAT_ICON_BUTTON_SIZE = 32;
12
12
 
13
+ /**
14
+ * Side and icon per size: `sm` is the web `icon-sm` (32, 16 icon), `xs` the
15
+ * web `icon-xs` (28, 14 icon) and `md` the web `size-9` (36, 16 icon), Eva's
16
+ * composer send button. `touch` has no web twin: a 44 square with a 16 icon,
17
+ * for a row of ghost buttons a thumb taps (the message actions), where the
18
+ * web 28 squares 2 apart are a mouse's density.
19
+ */
20
+ const CHAT_ICON_BUTTON = {
21
+ xs: { side: 28, icon: 14, className: "h-7 w-7" },
22
+ sm: { side: CHAT_ICON_BUTTON_SIZE, icon: 16, className: "h-8 w-8" },
23
+ md: { side: 36, icon: 16, className: "h-9 w-9" },
24
+ touch: { side: MIN_TOUCH_TARGET, icon: 16, className: "h-11 w-11" },
25
+ } as const;
26
+
27
+ export type ChatIconButtonSize = keyof typeof CHAT_ICON_BUTTON;
28
+
13
29
  export type ChatIconButtonProps = Omit<ButtonProps, "children" | "size" | "icon"> & {
14
30
  icon: ComponentType<IconProps>;
15
31
  /** Names the button for VoiceOver and TalkBack; it has no text. */
16
32
  label: string;
17
33
  /** Default: the variant's label colour. */
18
34
  iconColor?: string;
35
+ /** Default `sm`. Larger text paints each 44 with a 20 icon. */
36
+ size?: ChatIconButtonSize;
37
+ /**
38
+ * `"square"` (default) slops to 44 on every side. `"vertical"` slops only
39
+ * the height to 44, for a dense row (the message actions sit 2 to 4 apart),
40
+ * where sideways slop would overlap the next button and steal its taps.
41
+ * `{ left, right }` is `"vertical"` plus that much on each side, for the
42
+ * outer side of a button at the end of such a row.
43
+ */
44
+ slop?: "square" | "vertical" | { left: number; right: number };
19
45
  };
20
46
 
21
47
  /**
22
- * A round `Button` at the web `icon-sm` size (32), with the slop to 44. With
23
- * larger text it is painted 44 with a 20 icon. The message
48
+ * A round `Button` at the web `icon-sm` size (32), or another
49
+ * `ChatIconButtonSize`, with the slop to 44. With larger text it is painted
50
+ * 44 with a 20 icon. The message
24
51
  * actions, the composer tools and its send button, and the code copy button.
25
52
  * Round from the `Button` pill default; `shape="rounded"` gives radius 16.
26
53
  */
@@ -29,23 +56,31 @@ export function ChatIconButton({
29
56
  label,
30
57
  iconColor,
31
58
  variant = "ghost",
59
+ size = "sm",
60
+ slop = "square",
32
61
  className,
33
62
  ...props
34
63
  }: ChatIconButtonProps) {
35
64
  const scheme = useScheme();
36
65
  const largerText = useLargerText();
37
- const size = largerText ? MIN_TOUCH_TARGET : CHAT_ICON_BUTTON_SIZE;
66
+ const metrics = largerText
67
+ ? { side: MIN_TOUCH_TARGET, icon: 20, className: "h-11 w-11" }
68
+ : CHAT_ICON_BUTTON[size];
38
69
  const color = iconColor ?? buttonColors(variant, scheme)?.color ?? tokens.color.foreground[scheme];
39
70
  return (
40
71
  <Button
41
72
  variant={variant}
42
73
  size="icon"
43
74
  accessibilityLabel={label}
44
- hitSlop={touchSlop(size)}
45
- className={cn(largerText ? "h-11 w-11" : "h-8 w-8", className)}
75
+ hitSlop={
76
+ slop === "square"
77
+ ? touchSlop(metrics.side)
78
+ : { ...touchSlop(MIN_TOUCH_TARGET, metrics.side), ...(slop === "vertical" ? null : slop) }
79
+ }
80
+ className={cn(metrics.className, className)}
46
81
  {...props}
47
82
  >
48
- <Icon size={largerText ? 20 : 16} color={color} />
83
+ <Icon size={metrics.icon} color={color} />
49
84
  </Button>
50
85
  );
51
86
  }
package/src/ai/index.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  // `@vvedantb/native-ui/ai` entry only, so apps without a chat do not bundle
3
3
  // them. Generic: no model names and no app logic.
4
4
  export { useStreamingDisclosure } from "./use-streaming-disclosure";
5
- export { chatTextSize, formatElapsed, MONO_FONT } from "./chat-text";
5
+ export { chatTextSize, formatElapsed, META_TEXT_STYLE, MONO_FONT } from "./chat-text";
6
6
  export type { ChatTextKind } from "./chat-text";
7
7
  export { Shimmer, SHIMMER_LOW_OPACITY } from "./shimmer";
8
8
  export type { ShimmerProps } from "./shimmer";
@@ -17,8 +17,10 @@ export type { CollapsibleProps, CollapsibleTriggerProps } from "./collapsible";
17
17
  export {
18
18
  Conversation,
19
19
  ConversationContent,
20
+ ConversationList,
20
21
  ConversationEmptyState,
21
22
  ConversationScrollButton,
23
+ ConversationDock,
22
24
  useConversation,
23
25
  isScrolledToBottom,
24
26
  AT_BOTTOM_THRESHOLD,
@@ -26,8 +28,11 @@ export {
26
28
  export type {
27
29
  ConversationProps,
28
30
  ConversationContentProps,
31
+ ConversationListProps,
32
+ ConversationScroller,
29
33
  ConversationEmptyStateProps,
30
34
  ConversationScrollButtonProps,
35
+ ConversationDockProps,
31
36
  } from "./conversation";
32
37
  export {
33
38
  Message,
@@ -102,18 +107,48 @@ export type {
102
107
  } from "./chain-of-thought";
103
108
  export { ActivityStep } from "./activity-step";
104
109
  export type { ActivityStepProps, ActivityStepStatus } from "./activity-step";
110
+ // The web timeline's pure logic (byte-identical copies) and its folded rules.
111
+ // The step type is `ActivityTimelineStep` here: `ActivityStep` is the line above.
112
+ export { activityStepHasRichDetail } from "./activity-step-types";
113
+ export type {
114
+ ActivityStep as ActivityTimelineStep,
115
+ ActivityStepEdit,
116
+ ActivityStepOutput,
117
+ ActivityTodoItem,
118
+ } from "./activity-step-types";
119
+ export {
120
+ deriveActionGroupSummary,
121
+ deriveReadableCommandDisplay,
122
+ deriveStepRowPresentation,
123
+ resolveCommandVisualKind,
124
+ } from "./activity-step-label";
125
+ export type { CommandVisualKind, StepRowPresentation } from "./activity-step-label";
126
+ export { activityRowKey, activitySegmentKey, buildActivityRows, groupActivityRows } from "./activity-tasks-utils";
127
+ export type { ActivityRow, ActivitySegment } from "./activity-tasks-utils";
128
+ export {
129
+ ACTIVITY_MAX_VISIBLE_BLOCKS,
130
+ ACTIVITY_MAX_VISIBLE_CHILDREN,
131
+ activityChildRows,
132
+ activityStepSeconds,
133
+ foldedActivitySegments,
134
+ } from "./activity-timeline";
105
135
  export {
106
136
  PromptInput,
107
137
  PromptInputButton,
138
+ PromptInputChip,
108
139
  PromptInputSubmit,
140
+ PROMPT_INPUT_CHIP,
109
141
  isGenerating,
110
142
  } from "./prompt-input";
111
143
  export type {
112
144
  ChatStatus,
145
+ PromptInputChipProps,
113
146
  PromptInputProps,
114
147
  PromptInputButtonProps,
115
148
  PromptInputSubmitProps,
116
149
  } from "./prompt-input";
150
+ export { PromptInputMenu, PromptInputMenuItem, PROMPT_INPUT_MENU, promptInputMenuHeight } from "./prompt-input-menu";
151
+ export type { PromptInputMenuProps, PromptInputMenuItemProps } from "./prompt-input-menu";
117
152
  export { Suggestions, Suggestion } from "./suggestion";
118
153
  export type { SuggestionsProps, SuggestionProps } from "./suggestion";
119
154
  export { Sources, SourcesTrigger, SourcesContent, Source, sourcesLabel } from "./sources";
@@ -146,6 +181,8 @@ export { ChoiceQuestion, choiceAnswers } from "./choice-question";
146
181
  export type { ChoiceQuestionProps, ChoiceQuestionItem } from "./choice-question";
147
182
  export { ActivityStepDetail } from "./activity-step-detail";
148
183
  export type { ActivityStepDetailProps, ActivityStepDetailStep } from "./activity-step-detail";
184
+ export { UnifiedDiff, DiffStat, UNIFIED_DIFF } from "./unified-diff";
185
+ export type { UnifiedDiffProps, UnifiedDiffLine, DiffStatProps } from "./unified-diff";
149
186
  export { ToolApprovalCard, ToolDraftCard, summarizeArgs, ARGS_MAX_CHARS } from "./tool-approval-card";
150
187
  export type { ToolApprovalCardProps, ToolDraftCardProps, ToolDecision } from "./tool-approval-card";
151
188
  export { SubagentRunCard, SubagentResultCard, subagentStatusColors } from "./subagent-cards";
@@ -22,6 +22,9 @@ export type MessageSheetAction = {
22
22
  onPress: () => void;
23
23
  };
24
24
 
25
+ /** The sheet's default title, also the message's `longpress` action label. */
26
+ export const MESSAGE_ACTIONS_TITLE = "Message actions";
27
+
25
28
  export type MessageActionSheetProps = {
26
29
  open: boolean;
27
30
  /** Called on a backdrop tap, Cancel, Android back and after an action runs. */
@@ -45,7 +48,7 @@ export function MessageActionSheet({
45
48
  open,
46
49
  onClose,
47
50
  actions,
48
- title = "Message actions",
51
+ title = MESSAGE_ACTIONS_TITLE,
49
52
  cancelLabel = "Cancel",
50
53
  testID = "message-action-sheet",
51
54
  }: MessageActionSheetProps) {
@@ -16,9 +16,9 @@ import { useLargerText } from "../theme";
16
16
  import { tokens } from "../tokens";
17
17
  import { useReduceMotion } from "../useReduceMotion";
18
18
  import { useScheme } from "../useScheme";
19
- import { chatTextSize } from "./chat-text";
20
- import { ChatIconButton } from "./icon-button";
21
- import { MessageActionSheet, type MessageSheetAction } from "./message-action-sheet";
19
+ import { META_TEXT_STYLE, chatTextSize } from "./chat-text";
20
+ import { ChatIconButton, type ChatIconButtonSize } from "./icon-button";
21
+ import { MESSAGE_ACTIONS_TITLE, MessageActionSheet, type MessageSheetAction } from "./message-action-sheet";
22
22
  import { ChatTextColorContext, ResponseText } from "./response-text";
23
23
 
24
24
  export type MessageRole = "user" | "assistant" | "system";
@@ -68,13 +68,13 @@ function MessageHighlight() {
68
68
  const wash = useSharedValue(reduceMotion ? 0.5 : 1);
69
69
  useEffect(() => {
70
70
  if (reduceMotion) {
71
- wash.value = 0.5;
71
+ wash.set(0.5);
72
72
  return;
73
73
  }
74
- wash.value = 1;
75
- wash.value = withTiming(0, { duration: MESSAGE_HIGHLIGHT_MS });
74
+ wash.set(1);
75
+ wash.set(withTiming(0, { duration: MESSAGE_HIGHLIGHT_MS }));
76
76
  }, [wash, reduceMotion]);
77
- const animatedStyle = useAnimatedStyle(() => ({ opacity: wash.value }));
77
+ const animatedStyle = useAnimatedStyle(() => ({ opacity: wash.get() }));
78
78
  return (
79
79
  <Animated.View
80
80
  testID="message-highlight"
@@ -100,9 +100,9 @@ function MessageHighlight() {
100
100
  *
101
101
  * With `onLongPress` or `actions` the message is a `Pressable` (same layout,
102
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.
103
+ * `MessageActionSheet`; TalkBack reaches it as the `longpress` action. It stays
104
+ * a non-accessible container so its buttons and links keep their own focus:
105
+ * also offer the actions in a `MessageActionsBar` for screen reader users. `highlighted` draws the quote-jump highlight.
106
106
  */
107
107
  export function Message({
108
108
  from,
@@ -120,10 +120,14 @@ export function Message({
120
120
  const [sheetOpen, setSheetOpen] = useState(false);
121
121
  const hasActions = actions != null && actions.length > 0;
122
122
  const longPressable = onLongPress != null || hasActions;
123
- const layout = [
123
+ const longPress = () => {
124
+ onLongPress?.();
125
+ if (hasActions) setSheetOpen(true);
126
+ };
127
+ const layout: ViewProps["style"] = [
124
128
  { alignSelf: user ? "flex-end" : "flex-start", alignItems: user ? "flex-end" : "stretch" },
125
129
  style,
126
- ] as const;
130
+ ];
127
131
  const merged = cn("w-full max-w-[95%] gap-2", className);
128
132
  const parts = (
129
133
  <>
@@ -139,10 +143,12 @@ export function Message({
139
143
  <Pressable
140
144
  accessible={false}
141
145
  className={merged}
142
- style={layout as never}
143
- onLongPress={() => {
144
- onLongPress?.();
145
- if (hasActions) setSheetOpen(true);
146
+ style={layout}
147
+ onLongPress={longPress}
148
+ // TalkBack's actions menu reaches the same sheet as a long press.
149
+ accessibilityActions={[{ name: "longpress", label: actionSheetTitle ?? MESSAGE_ACTIONS_TITLE }]}
150
+ onAccessibilityAction={(event) => {
151
+ if (event.nativeEvent.actionName === "longpress") longPress();
146
152
  }}
147
153
  {...props}
148
154
  >
@@ -181,8 +187,8 @@ export function userBubbleStyle(scheme: "light" | "dark") {
181
187
  /**
182
188
  * A user bubble's look. `primary` is the web default (`bg-primary` with the
183
189
  * label colour). `tint` is the chat override Eva uses (`rounded-surface
184
- * bg-primary/10 px-3 py-2 text-foreground`): a 10% wash of the primary colour
185
- * with foreground text and every corner at 16.
190
+ * bg-primary/10 text-foreground`, still `px-4 py-3`): a 10% wash of the
191
+ * primary colour with foreground text and every corner at 16.
186
192
  */
187
193
  export type MessageBubble = "primary" | "tint";
188
194
 
@@ -239,9 +245,9 @@ export type MessageContentProps = ViewProps & {
239
245
  };
240
246
 
241
247
  /**
242
- * The message body. A user message is a filled bubble: by default `px-4 py-3`
243
- * with the primary fill and label colour, or with `bubble="tint"` `px-3 py-2`
244
- * on a 10% primary wash with the foreground colour. An assistant message has
248
+ * The message body. A user message is a filled bubble, `px-4 py-3`: by
249
+ * default with the primary fill and label colour, or with `bubble="tint"` on
250
+ * a 10% primary wash with the foreground colour. An assistant message has
245
251
  * no fill and the foreground colour. A string child is drawn as
246
252
  * `ResponseText`, and `ResponseText` inside takes the colour.
247
253
  */
@@ -256,7 +262,7 @@ export function MessageContent({ children, bubble = "primary", quote, className,
256
262
  return (
257
263
  <ChatTextColorContext.Provider value={color}>
258
264
  <View
259
- className={cn("min-w-0 max-w-full gap-2", user && (tint ? "px-3 py-2" : "px-4 py-3"), className)}
265
+ className={cn("min-w-0 max-w-full gap-2", user && "px-4 py-3", className)}
260
266
  style={[bubbleStyle, style]}
261
267
  {...props}
262
268
  >
@@ -273,21 +279,33 @@ export function MessageContent({ children, bubble = "primary", quote, className,
273
279
  );
274
280
  }
275
281
 
276
- export type MessageMetaProps = { children: ReactNode; className?: string };
282
+ export type MessageMetaProps = {
283
+ children: ReactNode;
284
+ /**
285
+ * Default `caption` (`text-xs`, 12 / 16). `2xs` is the web `text-2xs` token
286
+ * (11 / 16), as Eva's web chat. Larger text draws both at the caption step.
287
+ */
288
+ size?: "caption" | "2xs";
289
+ className?: string;
290
+ };
277
291
 
278
292
  /**
279
293
  * The muted clock and duration after a message's actions, as Eva's web chat
280
- * (`text-2xs tabular-nums text-muted-foreground/60`, "10:21 PM · 1m"): the
281
- * caption size on phones, the muted colour at 60%, tabular figures.
294
+ * (`tabular-nums text-muted-foreground/60`, "10:21 PM · 1m"): the caption
295
+ * size (or `size="2xs"`), the muted colour at 60%, tabular figures.
282
296
  */
283
- export function MessageMeta({ children, className }: MessageMetaProps) {
297
+ export function MessageMeta({ children, size = "caption", className }: MessageMetaProps) {
284
298
  const scheme = useScheme();
285
299
  const largerText = useLargerText();
286
- const size = chatTextSize("caption", largerText);
300
+ const caption = chatTextSize("caption", largerText);
301
+ const tiny = size === "2xs" && !largerText;
287
302
  return (
288
303
  <Text
289
- className={cn(size, "font-instrument-sans", className)}
290
- style={[sizeStyle(size), { color: withAlpha(tokens.color.muted[scheme], 0.6), fontVariant: ["tabular-nums"] }]}
304
+ className={cn(!tiny && caption, "font-instrument-sans", className)}
305
+ style={[
306
+ tiny ? META_TEXT_STYLE : sizeStyle(caption),
307
+ { color: withAlpha(tokens.color.muted[scheme], 0.6), fontVariant: ["tabular-nums"] },
308
+ ]}
291
309
  >
292
310
  {children}
293
311
  </Text>
@@ -325,12 +343,20 @@ export type MessageActionProps = {
325
343
  */
326
344
  selected?: boolean;
327
345
  disabled?: boolean;
346
+ /**
347
+ * Default `sm`, the web `icon-sm` (32, 16 icon). `xs` is the web `icon-xs`
348
+ * (28, 14 icon), as Eva's web chat; `touch` is a whole 44 square (16 icon)
349
+ * for a phone, so the row needs no slop.
350
+ */
351
+ size?: ChatIconButtonSize;
328
352
  testID?: string;
329
353
  };
330
354
 
331
355
  /**
332
- * A ghost icon button under a message: the web `icon-sm` (32, 16 icon) with
333
- * the slop to 44. Copying is up to the app (`onPress`): React Native's own
356
+ * A ghost icon button under a message: the web `icon-sm` (32, 16 icon), or
357
+ * `icon-xs` with `size="xs"`, with the slop to 44 high. It has no sideways
358
+ * slop: the actions sit 2 to 4 apart, and slop there would overlap the next
359
+ * one, so a tap on Copy could fork or rate instead. Copying is up to the app (`onPress`): React Native's own
334
360
  * `Clipboard` is deprecated, and native-ui adds no clipboard package.
335
361
  */
336
362
  export function MessageAction({
@@ -340,6 +366,7 @@ export function MessageAction({
340
366
  onPress,
341
367
  selected = false,
342
368
  disabled,
369
+ size,
343
370
  testID,
344
371
  }: MessageActionProps) {
345
372
  const scheme = useScheme();
@@ -353,6 +380,8 @@ export function MessageAction({
353
380
  accessibilityState={{ selected }}
354
381
  onPress={onPress}
355
382
  disabled={disabled}
383
+ size={size}
384
+ slop="vertical"
356
385
  testID={testID}
357
386
  />
358
387
  );
@@ -0,0 +1,197 @@
1
+ import { Children, useEffect, type ReactNode } from "react";
2
+ import { BackHandler, Pressable, ScrollView, Text, View, useWindowDimensions } from "react-native";
3
+ import { cn } from "../cn";
4
+ import { MIN_TOUCH_TARGET, sizeStyle, textStyle, touchSlop, 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
+
10
+ /**
11
+ * The web panel picker height (`computePanelPopupPlacement`): at most 340 and
12
+ * at least 160, here a share of the window, as a phone has the keyboard up.
13
+ */
14
+ export const PROMPT_INPUT_MENU = { maxHeight: 340, minHeight: 160, windowShare: 0.35 } as const;
15
+
16
+ /** Menu height for a window height: the share, kept inside the web's 160 to 340. */
17
+ export function promptInputMenuHeight(windowHeight: number): number {
18
+ return Math.round(
19
+ Math.max(PROMPT_INPUT_MENU.minHeight, Math.min(PROMPT_INPUT_MENU.maxHeight, windowHeight * PROMPT_INPUT_MENU.windowShare)),
20
+ );
21
+ }
22
+
23
+ export type PromptInputMenuProps = {
24
+ /** Names the list for VoiceOver and TalkBack, as the web listbox `aria-label` (such as "Mentions" or "Skills"). */
25
+ title: string;
26
+ /** `PromptInputMenuItem`s. With none, `empty` shows instead. */
27
+ children?: ReactNode;
28
+ /** Shown when there are no rows. Default "No matches.", as the web. */
29
+ empty?: ReactNode;
30
+ /**
31
+ * Closes the picker, as Escape does on the web. Android back calls it while
32
+ * the picker is mounted, before the screen behind gets the press.
33
+ */
34
+ onDismiss?: () => void;
35
+ testID?: string;
36
+ };
37
+
38
+ /**
39
+ * The web `@` / `/` picker (`MentionPickerPopup`, `panel` layout) for touch:
40
+ * the composer's full width, docked right above the field, so it rides the
41
+ * keyboard with it. Give it to `PromptInput` as `menu`. The popover surface
42
+ * (`rounded-surface bg-popover`, the field border in place of the web shadow
43
+ * ring, as the AI parts draw no shadow) holds a `py-1` list that scrolls past
44
+ * `promptInputMenuHeight`. Taps reach a row while the keyboard
45
+ * is up, so picking never drops the keyboard.
46
+ */
47
+ export function PromptInputMenu({ title, children, empty = "No matches.", onDismiss, testID }: PromptInputMenuProps) {
48
+ const scheme = useScheme();
49
+ // Back closes the picker first. An effect: the subscription is a native one.
50
+ useEffect(() => {
51
+ if (!onDismiss) return;
52
+ const subscription = BackHandler.addEventListener("hardwareBackPress", () => {
53
+ onDismiss();
54
+ return true;
55
+ });
56
+ return () => subscription.remove();
57
+ }, [onDismiss]);
58
+ const { height } = useWindowDimensions();
59
+ const hasRows = Children.toArray(children).length > 0;
60
+ return (
61
+ <View
62
+ testID={testID}
63
+ className="overflow-hidden border"
64
+ style={{
65
+ backgroundColor: palette.overlay[scheme],
66
+ borderColor: tokens.color.border[scheme],
67
+ borderRadius: tokens.radius.lg,
68
+ maxHeight: promptInputMenuHeight(height),
69
+ }}
70
+ >
71
+ {hasRows ? (
72
+ <ScrollView
73
+ accessibilityRole="list"
74
+ accessibilityLabel={title}
75
+ keyboardShouldPersistTaps="always"
76
+ contentContainerClassName="py-1"
77
+ >
78
+ {children}
79
+ </ScrollView>
80
+ ) : (
81
+ // Web: `px-3 py-2.5 text-xs leading-relaxed text-muted-foreground`.
82
+ <Text
83
+ className="px-3 py-2.5 font-instrument-sans text-xs"
84
+ style={[sizeStyle("text-xs", "relaxed"), textStyle("muted", scheme)]}
85
+ >
86
+ {empty}
87
+ </Text>
88
+ )}
89
+ </View>
90
+ );
91
+ }
92
+
93
+ /** Web badge chip: `rounded-md bg-muted px-1.5 py-0.5 text-3xs font-medium` (10). */
94
+ const BADGE_TEXT = { fontSize: 10, lineHeight: 12 } as const;
95
+
96
+ export type PromptInputMenuItemProps = {
97
+ /** The character that inserts this row, muted before the label: `@` or `/`. Left out when `leading` names a person. */
98
+ prefix?: string;
99
+ label: string;
100
+ /** One muted line under the label, such as a skill's description. */
101
+ description?: string;
102
+ /** Before the text, such as a teammate's `Avatar` (24). */
103
+ leading?: ReactNode;
104
+ /** At the end in place of `badge`, such as a 16 kind icon. */
105
+ trailing?: ReactNode;
106
+ /** A small source chip at the end (such as "Eva" or "Claude"), with an optional 11 `badgeIcon`. */
107
+ badge?: string;
108
+ badgeIcon?: ReactNode;
109
+ /** The row Return picks, tinted as the web's keyboard-selected row. */
110
+ selected?: boolean;
111
+ onPress?: () => void;
112
+ testID?: string;
113
+ };
114
+
115
+ /** A description longer than this is cut with "…", as the web `previewOneLine`. */
116
+ const DESCRIPTION_MAX = 72;
117
+
118
+ function oneLine(text: string): string {
119
+ const line = text.replace(/\s+/g, " ").trim();
120
+ return line.length <= DESCRIPTION_MAX ? line : `${line.slice(0, DESCRIPTION_MAX - 1)}…`;
121
+ }
122
+
123
+ /**
124
+ * One picker row, the web `MentionRow` in its option button
125
+ * (`mx-1 rounded-lg px-2 text-sm`), at least 44 high. `selected` takes the
126
+ * web `bg-primary/15` (`/25` dark) and a medium label; a press fades to 70%,
127
+ * as the composer chips, in place of the web hover fill.
128
+ */
129
+ export function PromptInputMenuItem({
130
+ prefix,
131
+ label,
132
+ description,
133
+ leading,
134
+ trailing,
135
+ badge,
136
+ badgeIcon,
137
+ selected = false,
138
+ onPress,
139
+ testID,
140
+ }: PromptInputMenuItemProps) {
141
+ const scheme = useScheme();
142
+ const largerText = useLargerText();
143
+ const muted = textStyle("muted", scheme);
144
+ const labelSize = sizeStyle(largerText ? "text-lg" : "text-sm");
145
+ const detail = description ? oneLine(description) : undefined;
146
+ const end =
147
+ trailing ??
148
+ (badge ? (
149
+ <View
150
+ className="shrink-0 flex-row items-center gap-1 px-1.5 py-0.5"
151
+ style={{ backgroundColor: tokens.color.surfaceSecondary[scheme], borderRadius: 6 }}
152
+ >
153
+ {badgeIcon}
154
+ <Text className="font-instrument-sans-medium" style={[BADGE_TEXT, muted, typeStyle("medium")]}>
155
+ {badge}
156
+ </Text>
157
+ </View>
158
+ ) : null);
159
+ return (
160
+ <Pressable
161
+ testID={testID}
162
+ accessibilityRole="button"
163
+ accessibilityState={{ selected }}
164
+ accessibilityLabel={[`${prefix ?? ""}${label}`, detail, badge].filter(Boolean).join(", ")}
165
+ onPress={onPress}
166
+ // Already 44 high and the list's width: no slop past the row.
167
+ hitSlop={touchSlop(MIN_TOUCH_TARGET)}
168
+ // A plain style: NativeWind drops a function `style` beside `className`.
169
+ className={cn("mx-1 flex-row items-center gap-2 px-2 py-1.5 active:opacity-70", largerText && "py-2")}
170
+ style={{
171
+ minHeight: MIN_TOUCH_TARGET,
172
+ borderRadius: 8,
173
+ backgroundColor: selected ? withAlpha(palette.primary[scheme], scheme === "dark" ? 0.25 : 0.15) : "transparent",
174
+ }}
175
+ >
176
+ {leading}
177
+ <View className="min-w-0 flex-1 gap-0.5">
178
+ <View className="min-w-0 flex-row items-center gap-1.5">
179
+ <Text
180
+ numberOfLines={1}
181
+ className={cn("min-w-0 flex-1", selected ? "font-instrument-sans-medium" : "font-instrument-sans")}
182
+ style={[labelSize, textStyle("foreground", scheme), selected ? typeStyle("medium") : null]}
183
+ >
184
+ {prefix ? <Text style={muted}>{prefix}</Text> : null}
185
+ {label}
186
+ </Text>
187
+ {end}
188
+ </View>
189
+ {detail ? (
190
+ <Text numberOfLines={1} className="font-instrument-sans text-xs" style={[sizeStyle("text-xs"), muted]}>
191
+ {detail}
192
+ </Text>
193
+ ) : null}
194
+ </View>
195
+ </Pressable>
196
+ );
197
+ }