@vvedantb/native-ui 0.7.0 → 0.9.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.
@@ -0,0 +1,297 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import { Pressable, ScrollView, Text, View } from "react-native";
3
+ import { IconArrowLeft, IconArrowRight, IconCheck, IconPencil } from "@tabler/icons-react-native";
4
+ import { Badge } from "../Badge";
5
+ import { Button } from "../Button";
6
+ import { Input } from "../Input";
7
+ import { sizeStyle, textStyle, typeStyle } from "../controlStyles";
8
+ import { palette } from "../palette";
9
+ import { useLargerText } from "../theme";
10
+ import { tokens } from "../tokens";
11
+ import { useScheme } from "../useScheme";
12
+ import { chatTextSize } from "./chat-text";
13
+
14
+ /** One question, as an agent's AskUserQuestion sends it. */
15
+ export type ChoiceQuestionItem = {
16
+ question: string;
17
+ /** Short tag shown as a badge beside the question. */
18
+ header: string;
19
+ multiSelect: boolean;
20
+ options: ReadonlyArray<{ label: string; description: string }>;
21
+ };
22
+
23
+ export type ChoiceQuestionProps = {
24
+ questions: ReadonlyArray<ChoiceQuestionItem>;
25
+ /**
26
+ * Question text to the picked label, or the typed "Other" answer.
27
+ * Multi-select joins labels with ", " (the shape AskUserQuestion reads).
28
+ */
29
+ onSubmit: (answers: Record<string, string>) => void | Promise<void>;
30
+ /** Shows a spinner on the submit button and blocks input. */
31
+ loading?: boolean;
32
+ /** Default "Other...". */
33
+ otherLabel?: string;
34
+ /** Default "Type your answer...". */
35
+ otherPlaceholder?: string;
36
+ /** Inside the web dock card (`rounded-surface bg-card p-3`, scrolling past 60% of the screen). Default `true`. */
37
+ framed?: boolean;
38
+ };
39
+
40
+ const LETTERS = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
41
+
42
+ /** The structured answer for `onSubmit`. Exported for tests. */
43
+ export function choiceAnswers(
44
+ questions: ReadonlyArray<ChoiceQuestionItem>,
45
+ picked: Readonly<Record<number, readonly string[]>>,
46
+ other: Readonly<Record<number, string>>,
47
+ otherOn: Readonly<Record<number, boolean>>,
48
+ ): Record<string, string> {
49
+ const result: Record<string, string> = {};
50
+ questions.forEach((question, index) => {
51
+ if (otherOn[index]) {
52
+ result[question.question] = (other[index] ?? "").trim();
53
+ return;
54
+ }
55
+ const selected = picked[index] ?? [];
56
+ result[question.question] = question.multiSelect ? selected.join(", ") : (selected[0] ?? "");
57
+ });
58
+ return result;
59
+ }
60
+
61
+ /**
62
+ * An agent's blocking multiple-choice question, as the web chat's question
63
+ * dock (`ChatQuestionDock` + `MultipleChoiceQuestion`): the question with its
64
+ * header badge and step count, lettered option rows with descriptions, an
65
+ * "Other..." row that opens a text field, then Back and Next or Submit. One
66
+ * question at a time.
67
+ */
68
+ export function ChoiceQuestion({
69
+ questions,
70
+ onSubmit,
71
+ loading = false,
72
+ otherLabel = "Other...",
73
+ otherPlaceholder = "Type your answer...",
74
+ framed = true,
75
+ }: ChoiceQuestionProps) {
76
+ const scheme = useScheme();
77
+ const largerText = useLargerText();
78
+ const icon = largerText ? 18 : 15;
79
+ const [step, setStep] = useState(0);
80
+ const [picked, setPicked] = useState<Record<number, string[]>>({});
81
+ const [other, setOther] = useState<Record<number, string>>({});
82
+ const [otherOn, setOtherOn] = useState<Record<number, boolean>>({});
83
+ const question = questions[step];
84
+ if (!question) return null;
85
+
86
+ const total = questions.length;
87
+ const isLast = step === total - 1;
88
+ const selected = picked[step] ?? [];
89
+ const hasAnswer = otherOn[step] ? (other[step] ?? "").trim() !== "" : selected.length > 0;
90
+
91
+ const toggle = (label: string) => {
92
+ const next = question.multiSelect
93
+ ? selected.includes(label)
94
+ ? selected.filter((item) => item !== label)
95
+ : [...selected, label]
96
+ : [label];
97
+ setPicked({ ...picked, [step]: next });
98
+ setOtherOn({ ...otherOn, [step]: false });
99
+ };
100
+
101
+ const next = () => {
102
+ if (!hasAnswer || loading) return;
103
+ if (isLast) void onSubmit(choiceAnswers(questions, picked, other, otherOn));
104
+ else setStep(step + 1);
105
+ };
106
+
107
+ const body = (
108
+ <View style={{ gap: 16 }}>
109
+ <View className="flex-row items-center justify-between" style={{ gap: 8 }}>
110
+ <Text
111
+ className="flex-1 font-instrument-sans-semibold"
112
+ // Web `text-[15px] leading-snug`; larger text steps up to 18.
113
+ style={[{ fontSize: largerText ? 18 : 15, lineHeight: largerText ? 24 : 20 }, textStyle("foreground", scheme), typeStyle("semibold")]}
114
+ >
115
+ {question.question}
116
+ </Text>
117
+ <View className="flex-row items-center" style={{ gap: 6 }}>
118
+ {/* Web `text-3xs`: a compact badge beside the question. */}
119
+ {question.header ? (
120
+ <Badge variant="secondary" className="px-2 py-0.5" textClassName={largerText ? "text-sm" : "text-xs"}>
121
+ {question.header}
122
+ </Badge>
123
+ ) : null}
124
+ {total > 1 ? (
125
+ <Text className="font-instrument-sans-medium" style={[sizeStyle(chatTextSize("caption", largerText)), textStyle("muted", scheme), typeStyle("medium")]}>
126
+ {`${step + 1}/${total}`}
127
+ </Text>
128
+ ) : null}
129
+ </View>
130
+ </View>
131
+
132
+ <View style={{ gap: 4 }}>
133
+ {question.options.map((option, index) => (
134
+ <ChoiceRow
135
+ key={`${option.label}-${index}`}
136
+ selected={!otherOn[step] && selected.includes(option.label)}
137
+ disabled={loading}
138
+ mark={LETTERS[index] ?? String(index + 1)}
139
+ label={option.label}
140
+ description={option.description}
141
+ onPress={() => toggle(option.label)}
142
+ />
143
+ ))}
144
+ <ChoiceRow
145
+ selected={otherOn[step] ?? false}
146
+ disabled={loading}
147
+ mark="other"
148
+ label={otherLabel}
149
+ onPress={() => {
150
+ setOtherOn({ ...otherOn, [step]: true });
151
+ setPicked({ ...picked, [step]: [] });
152
+ }}
153
+ >
154
+ {otherOn[step] ? (
155
+ <View style={{ marginTop: 8, marginLeft: 36 }}>
156
+ <Input
157
+ value={other[step] ?? ""}
158
+ onChangeText={(text: string) => setOther({ ...other, [step]: text })}
159
+ placeholder={otherPlaceholder}
160
+ editable={!loading}
161
+ autoFocus
162
+ onSubmitEditing={next}
163
+ />
164
+ </View>
165
+ ) : null}
166
+ </ChoiceRow>
167
+ </View>
168
+
169
+ <View className="flex-row items-center" style={{ gap: 8 }}>
170
+ {total > 1 && step > 0 ? (
171
+ <View className="flex-1">
172
+ <Button
173
+ variant="ghost"
174
+ disabled={loading}
175
+ onPress={() => setStep(step - 1)}
176
+ startContent={<IconArrowLeft size={icon} strokeWidth={2.5} color={tokens.color.foreground[scheme]} />}
177
+ >
178
+ Back
179
+ </Button>
180
+ </View>
181
+ ) : null}
182
+ <View className="flex-1">
183
+ <Button
184
+ disabled={!hasAnswer}
185
+ loading={loading}
186
+ onPress={next}
187
+ endContent={
188
+ loading ? null : <IconArrowRight size={icon} strokeWidth={2.5} color={palette.primaryForeground[scheme]} />
189
+ }
190
+ >
191
+ {isLast ? "Submit" : "Next"}
192
+ </Button>
193
+ </View>
194
+ </View>
195
+ </View>
196
+ );
197
+
198
+ if (!framed) return body;
199
+ return (
200
+ <View style={{ borderRadius: tokens.radius.lg, backgroundColor: tokens.color.surface[scheme], overflow: "hidden" }}>
201
+ <ScrollView style={{ maxHeight: 544 }} contentContainerStyle={{ padding: 12 }} keyboardShouldPersistTaps="handled">
202
+ {body}
203
+ </ScrollView>
204
+ </View>
205
+ );
206
+ }
207
+
208
+ /**
209
+ * One option row (web `Card` with `bg-secondary`, `py-2 px-2.5`, radius 16): a round 24 mark
210
+ * (30 with larger text; letter, or a pencil for "Other"; a check on the primary fill when picked),
211
+ * the label and the description. Picked: a 2-wide primary ring on the accent.
212
+ */
213
+ function ChoiceRow({
214
+ selected,
215
+ disabled,
216
+ mark,
217
+ label,
218
+ description,
219
+ onPress,
220
+ children,
221
+ }: {
222
+ selected: boolean;
223
+ disabled: boolean;
224
+ mark: string;
225
+ label: string;
226
+ description?: string;
227
+ onPress: () => void;
228
+ children?: ReactNode;
229
+ }) {
230
+ const scheme = useScheme();
231
+ const largerText = useLargerText();
232
+ const muted = tokens.color.muted[scheme];
233
+ const markSize = largerText ? 30 : 24;
234
+ const glyph = largerText ? 16 : 13;
235
+ return (
236
+ <View
237
+ style={{
238
+ // Web `Card` (rounded-surface, 16) with `py-2 px-2.5` inside a 1 border + 1 ring.
239
+ borderRadius: tokens.radius.lg,
240
+ borderWidth: 2,
241
+ borderColor: selected ? palette.primary[scheme] : "transparent",
242
+ backgroundColor: selected ? tokens.color.background[scheme] : palette.default[scheme],
243
+ opacity: disabled ? 0.5 : 1,
244
+ paddingVertical: 8,
245
+ paddingHorizontal: 10,
246
+ }}
247
+ >
248
+ <Pressable
249
+ accessibilityRole="button"
250
+ accessibilityLabel={label}
251
+ accessibilityState={{ selected, disabled }}
252
+ disabled={disabled}
253
+ onPress={onPress}
254
+ hitSlop={4}
255
+ className="flex-row"
256
+ style={{ gap: 12, alignItems: description ? "flex-start" : "center" }}
257
+ >
258
+ <View
259
+ style={{
260
+ width: markSize,
261
+ height: markSize,
262
+ // Web `rounded-md` (radius - 2 = 14 at the default 1rem) on a 24 box: round.
263
+ borderRadius: markSize / 2,
264
+ marginTop: description ? 2 : 0,
265
+ alignItems: "center",
266
+ justifyContent: "center",
267
+ backgroundColor: selected ? palette.primary[scheme] : palette.default[scheme],
268
+ }}
269
+ >
270
+ {selected ? (
271
+ <IconCheck size={glyph} strokeWidth={3} color={palette.primaryForeground[scheme]} />
272
+ ) : mark === "other" ? (
273
+ <IconPencil size={glyph} color={muted} />
274
+ ) : (
275
+ <Text className="font-instrument-sans-semibold" style={[typeStyle("semibold"), { fontSize: largerText ? 14 : 11, color: muted, letterSpacing: 0.4 }]}>
276
+ {mark}
277
+ </Text>
278
+ )}
279
+ </View>
280
+ <View className="min-w-0 flex-1">
281
+ <Text
282
+ className="font-instrument-sans-medium"
283
+ style={[sizeStyle(chatTextSize("small", largerText), "snug"), typeStyle("medium"), { color: selected ? palette.primary[scheme] : muted }]}
284
+ >
285
+ {label}
286
+ </Text>
287
+ {description ? (
288
+ <Text className="font-instrument-sans" style={[sizeStyle(chatTextSize("caption", largerText)), { color: muted, marginTop: 2 }]}>
289
+ {description}
290
+ </Text>
291
+ ) : null}
292
+ </View>
293
+ </Pressable>
294
+ {children}
295
+ </View>
296
+ );
297
+ }
@@ -1,8 +1,9 @@
1
- import { useEffect, useRef, useState } from "react";
1
+ import { Fragment, useEffect, useRef, useState } from "react";
2
2
  import { ScrollView, Text, View } from "react-native";
3
3
  import { IconCheck, IconCopy } from "@tabler/icons-react-native";
4
4
  import { cn } from "../cn";
5
5
  import { sizeStyle, textStyle } from "../controlStyles";
6
+ import { GITHUB_CODE_COLORS, codeTokenColor, loadPrismReactRenderer } from "../highlight";
6
7
  import { useLargerText } from "../theme";
7
8
  import { tokens } from "../tokens";
8
9
  import { useScheme } from "../useScheme";
@@ -54,20 +55,30 @@ export function CodeBlockCopyButton({ code, onCopy, timeout = 2000, label = "Cop
54
55
 
55
56
  export type CodeBlockProps = {
56
57
  code: string;
57
- /** Shown in the header. No highlighting: native-ui has no shiki. */
58
+ /**
59
+ * Shown in the header, and the Prism language for highlighting (`tsx`,
60
+ * `python`, `json`…). An unknown language draws plain text.
61
+ */
58
62
  language?: string;
63
+ /**
64
+ * Colour the code in the GitHub light and dark colours, as the web shiki
65
+ * block. Default `true`. Needs the optional peer `prism-react-renderer` and a
66
+ * `language`; without either the code is plain.
67
+ */
68
+ highlight?: boolean;
59
69
  /** With it, the header has a copy button. See `CodeBlockCopyButton`. */
60
70
  onCopy?: (code: string) => void | Promise<void>;
61
71
  className?: string;
62
72
  };
63
73
 
64
74
  /**
65
- * Plain code on the flat panel (web `flatPanelSubtle`: `bg-surface-secondary`,
66
- * no border or shadow) at radius 16. A header with the language and a copy
75
+ * Code on the flat panel (web `flatPanelSubtle`: `bg-surface-secondary`, no
76
+ * border or shadow) at radius 16. A header with the language and a copy
67
77
  * button when either is given. Long lines scroll sideways; the code is
68
- * selectable.
78
+ * selectable. With `prism-react-renderer` installed and a `language`, tokens
79
+ * take the GitHub colours of the scheme.
69
80
  */
70
- export function CodeBlock({ code, language, onCopy, className }: CodeBlockProps) {
81
+ export function CodeBlock({ code, language, highlight = true, onCopy, className }: CodeBlockProps) {
71
82
  const scheme = useScheme();
72
83
  const largerText = useLargerText();
73
84
  const size = chatTextSize("small", largerText);
@@ -96,9 +107,41 @@ export function CodeBlock({ code, language, onCopy, className }: CodeBlockProps)
96
107
  className={size}
97
108
  style={[sizeStyle(size), textStyle("foreground", scheme), { fontFamily: MONO_FONT }]}
98
109
  >
99
- {code}
110
+ {highlight && language ? <HighlightedCode code={code} language={language} scheme={scheme} /> : code}
100
111
  </Text>
101
112
  </ScrollView>
102
113
  </View>
103
114
  );
104
115
  }
116
+
117
+ /**
118
+ * Prism tokens as nested `Text` spans, one colour each, lines joined by
119
+ * newlines. They inherit the size and font of the code `Text` around them.
120
+ * Without `prism-react-renderer` it is the plain code.
121
+ */
122
+ function HighlightedCode({ code, language, scheme }: { code: string; language: string; scheme: "light" | "dark" }) {
123
+ const Highlight = loadPrismReactRenderer()?.Highlight;
124
+ if (!Highlight) return code;
125
+ return (
126
+ <Highlight
127
+ code={code.replace(/\n$/, "")}
128
+ language={language.toLowerCase()}
129
+ theme={{ plain: { color: GITHUB_CODE_COLORS[scheme].plain }, styles: [] }}
130
+ >
131
+ {({ tokens }) => (
132
+ <>
133
+ {tokens.map((line, lineIndex) => (
134
+ <Fragment key={lineIndex}>
135
+ {lineIndex > 0 ? "\n" : null}
136
+ {line.map((token, tokenIndex) => (
137
+ <Text key={tokenIndex} style={{ color: codeTokenColor(token.types, scheme) }}>
138
+ {token.content}
139
+ </Text>
140
+ ))}
141
+ </Fragment>
142
+ ))}
143
+ </>
144
+ )}
145
+ </Highlight>
146
+ );
147
+ }
package/src/ai/index.ts CHANGED
@@ -34,14 +34,18 @@ export {
34
34
  MessageContent,
35
35
  MessageActionsBar,
36
36
  MessageAction,
37
+ MessageMeta,
37
38
  MESSAGE_ACTIONS,
38
39
  userBubbleStyle,
40
+ tintBubbleStyle,
39
41
  } from "./message";
40
42
  export type {
41
43
  MessageRole,
42
44
  MessageProps,
43
45
  MessageContentProps,
46
+ MessageBubble,
44
47
  MessageActionsBarProps,
48
+ MessageMetaProps,
45
49
  MessageActionKind,
46
50
  MessageActionProps,
47
51
  } from "./message";
@@ -62,6 +66,7 @@ export {
62
66
  AgentTraceCollapsible,
63
67
  AgentTraceCollapsibleTrigger,
64
68
  AgentTraceCollapsibleContent,
69
+ AgentTraceFold,
65
70
  traceLineColor,
66
71
  } from "./trace";
67
72
  export type {
@@ -70,6 +75,7 @@ export type {
70
75
  AgentTraceIconProps,
71
76
  AgentTraceTitleProps,
72
77
  AgentTraceCollapsibleTriggerProps,
78
+ AgentTraceFoldProps,
73
79
  } from "./trace";
74
80
  export {
75
81
  ChainOfThought,
@@ -121,3 +127,7 @@ export { CodeBlock, CodeBlockCopyButton } from "./code-block";
121
127
  export type { CodeBlockProps, CodeBlockCopyButtonProps } from "./code-block";
122
128
  export { ResponseText, ChatTextColorContext } from "./response-text";
123
129
  export type { ResponseTextProps } from "./response-text";
130
+ export { ChoiceQuestion, choiceAnswers } from "./choice-question";
131
+ export type { ChoiceQuestionProps, ChoiceQuestionItem } from "./choice-question";
132
+ export { ActivityStepDetail } from "./activity-step-detail";
133
+ export type { ActivityStepDetailProps, ActivityStepDetailStep } from "./activity-step-detail";
@@ -1,5 +1,5 @@
1
- import { createContext, useContext, type ComponentType } from "react";
2
- import { View, type ViewProps } from "react-native";
1
+ import { createContext, useContext, type ComponentType, type ReactNode } from "react";
2
+ import { Text, View, type ViewProps } from "react-native";
3
3
  import {
4
4
  IconCopy,
5
5
  IconRefresh,
@@ -8,9 +8,12 @@ import {
8
8
  type IconProps,
9
9
  } from "@tabler/icons-react-native";
10
10
  import { cn } from "../cn";
11
- import { palette } from "../palette";
11
+ import { sizeStyle } from "../controlStyles";
12
+ import { palette, withAlpha } from "../palette";
13
+ import { useLargerText } from "../theme";
12
14
  import { tokens } from "../tokens";
13
15
  import { useScheme } from "../useScheme";
16
+ import { chatTextSize } from "./chat-text";
14
17
  import { ChatIconButton } from "./icon-button";
15
18
  import { ChatTextColorContext, ResponseText } from "./response-text";
16
19
 
@@ -56,24 +59,47 @@ export function userBubbleStyle(scheme: "light" | "dark") {
56
59
  };
57
60
  }
58
61
 
59
- export type MessageContentProps = ViewProps & { className?: string };
62
+ /**
63
+ * A user bubble's look. `primary` is the web default (`bg-primary` with the
64
+ * label colour). `tint` is the chat override Eva uses (`rounded-surface
65
+ * bg-primary/10 px-3 py-2 text-foreground`): a 10% wash of the primary colour
66
+ * with foreground text and every corner at 16.
67
+ */
68
+ export type MessageBubble = "primary" | "tint";
69
+
70
+ /** The `tint` bubble: the primary colour at 10% alpha, radius 16 on every corner. */
71
+ export function tintBubbleStyle(scheme: "light" | "dark") {
72
+ return {
73
+ backgroundColor: withAlpha(palette.primary[scheme], 0.1),
74
+ borderRadius: tokens.radius.lg,
75
+ };
76
+ }
77
+
78
+ export type MessageContentProps = ViewProps & {
79
+ /** User messages only. Default `"primary"`. */
80
+ bubble?: MessageBubble;
81
+ className?: string;
82
+ };
60
83
 
61
84
  /**
62
- * The message body. A user message is a filled bubble (`px-4 py-3`, the
63
- * primary fill and label colour); an assistant message has no fill and the
64
- * foreground colour. A string child is drawn as `ResponseText`, and
65
- * `ResponseText` inside takes the colour.
85
+ * The message body. A user message is a filled bubble: by default `px-4 py-3`
86
+ * with the primary fill and label colour, or with `bubble="tint"` `px-3 py-2`
87
+ * on a 10% primary wash with the foreground colour. An assistant message has
88
+ * no fill and the foreground colour. A string child is drawn as
89
+ * `ResponseText`, and `ResponseText` inside takes the colour.
66
90
  */
67
- export function MessageContent({ children, className, style, ...props }: MessageContentProps) {
91
+ export function MessageContent({ children, bubble = "primary", className, style, ...props }: MessageContentProps) {
68
92
  const from = useContext(MessageContext);
69
93
  const scheme = useScheme();
70
94
  const user = from === "user";
71
- const color = user ? palette.primaryForeground[scheme] : tokens.color.foreground[scheme];
95
+ const tint = user && bubble === "tint";
96
+ const color = user && !tint ? palette.primaryForeground[scheme] : tokens.color.foreground[scheme];
97
+ const bubbleStyle = !user ? null : tint ? tintBubbleStyle(scheme) : userBubbleStyle(scheme);
72
98
  return (
73
99
  <ChatTextColorContext.Provider value={color}>
74
100
  <View
75
- className={cn("min-w-0 max-w-full gap-2", user && "px-4 py-3", className)}
76
- style={[user ? userBubbleStyle(scheme) : null, style]}
101
+ className={cn("min-w-0 max-w-full gap-2", user && (tint ? "px-3 py-2" : "px-4 py-3"), className)}
102
+ style={[bubbleStyle, style]}
77
103
  {...props}
78
104
  >
79
105
  {typeof children === "string" ? <ResponseText>{children}</ResponseText> : children}
@@ -82,6 +108,27 @@ export function MessageContent({ children, className, style, ...props }: Message
82
108
  );
83
109
  }
84
110
 
111
+ export type MessageMetaProps = { children: ReactNode; className?: string };
112
+
113
+ /**
114
+ * The muted clock and duration after a message's actions, as Eva's web chat
115
+ * (`text-2xs tabular-nums text-muted-foreground/60`, "10:21 PM · 1m"): the
116
+ * caption size on phones, the muted colour at 60%, tabular figures.
117
+ */
118
+ export function MessageMeta({ children, className }: MessageMetaProps) {
119
+ const scheme = useScheme();
120
+ const largerText = useLargerText();
121
+ const size = chatTextSize("caption", largerText);
122
+ return (
123
+ <Text
124
+ className={cn(size, "font-instrument-sans", className)}
125
+ style={[sizeStyle(size), { color: withAlpha(tokens.color.muted[scheme], 0.6), fontVariant: ["tabular-nums"] }]}
126
+ >
127
+ {children}
128
+ </Text>
129
+ );
130
+ }
131
+
85
132
  export type MessageActionsBarProps = ViewProps & { className?: string };
86
133
 
87
134
  /** A row of `MessageAction`s under a message, as the web `flex items-center gap-1`. */
@@ -93,6 +93,15 @@ export type PromptInputProps = {
93
93
  header?: ReactNode;
94
94
  /** Before the send button, such as `PromptInputButton`s. */
95
95
  toolbar?: ReactNode;
96
+ /**
97
+ * `stacked` (default): the field over a footer of tools and send, the web
98
+ * `PromptInput`. `inline`: one row, as the web `Composer layout="inline"`
99
+ * (Eva's chat): `leading`, the field, `toolbar`, then send, in a 20-radius
100
+ * field with `p-1.5`, 44 high on one line.
101
+ */
102
+ layout?: "stacked" | "inline";
103
+ /** `inline` only: before the field, such as a + (attach) `PromptInputButton`. */
104
+ leading?: ReactNode;
96
105
  disabled?: boolean;
97
106
  /** Pad the bottom safe-area inset (home indicator). Default `true`; turn it off when a tab bar below already does. */
98
107
  safeBottom?: boolean;
@@ -102,6 +111,9 @@ export type PromptInputProps = {
102
111
  testID?: string;
103
112
  };
104
113
 
114
+ /** The web `Composer` inline radius, `rounded-[1.25rem]`. */
115
+ const INLINE_RADIUS = 20;
116
+
105
117
  /** The field grows to this height, then scrolls: about 6 lines of `text-base` (24 each, with `py-2.5`), and about 8 of `text-xl` with larger text. */
106
118
  const MAX_FIELD_HEIGHT = { regular: 160, larger: 240 } as const;
107
119
 
@@ -124,6 +136,8 @@ export function PromptInput({
124
136
  accessibilityLabel = "Message",
125
137
  header,
126
138
  toolbar,
139
+ layout = "stacked",
140
+ leading,
127
141
  disabled = false,
128
142
  safeBottom = true,
129
143
  keyboardVerticalOffset = 0,
@@ -156,35 +170,72 @@ export function PromptInput({
156
170
  className={cn("pt-2", className)}
157
171
  style={{ paddingBottom: bottom + 8, paddingLeft: insets.left + 12, paddingRight: insets.right + 12 }}
158
172
  >
159
- <View className="border" style={fieldStyle(scheme)}>
160
- {header ? <View className="flex-row flex-wrap gap-1 px-2 pt-2">{header}</View> : null}
161
- {/* The `Textarea` field without its `numberOfLines` (default 4), which on Android sets the line count: this one starts at one line and grows. */}
162
- <FieldInput
163
- multiline
164
- value={text}
165
- onChangeText={setText}
166
- placeholder={placeholder}
167
- accessibilityLabel={accessibilityLabel}
168
- editable={!disabled}
169
- textAlignVertical="top"
170
- className={cn(
171
- "min-h-11 border-0 px-3.5 py-2.5 text-base font-instrument-sans",
172
- largerText && "min-h-14 px-4 py-3 text-xl font-instrument-sans-medium",
173
- )}
174
- style={[
175
- fieldTextStyle(scheme),
176
- largerText ? typeStyle("medium") : null,
177
- {
178
- backgroundColor: "transparent",
179
- maxHeight: largerText ? MAX_FIELD_HEIGHT.larger : MAX_FIELD_HEIGHT.regular,
180
- },
181
- ]}
182
- />
183
- <View className="flex-row items-center justify-between gap-1 px-2 pb-2">
184
- <View className="min-w-0 shrink flex-row items-center gap-1">{toolbar}</View>
185
- <PromptInputSubmit status={status} onSubmit={submit} onStop={onStop} canSubmit={canSubmit} />
173
+ {layout === "inline" ? (
174
+ // Web `Composer` inline: `rounded-[1.25rem]` and a `p-1.5` row, items at the bottom as the field grows.
175
+ <View className="border" style={[fieldStyle(scheme), { borderRadius: INLINE_RADIUS }]}>
176
+ {header ? <View className="flex-row flex-wrap gap-1 px-1.5 pt-1.5">{header}</View> : null}
177
+ <View className="flex-row items-end gap-1 p-1.5">
178
+ {leading ? <View className="shrink-0 flex-row items-center gap-1">{leading}</View> : null}
179
+ <FieldInput
180
+ multiline
181
+ value={text}
182
+ onChangeText={setText}
183
+ placeholder={placeholder}
184
+ accessibilityLabel={accessibilityLabel}
185
+ editable={!disabled}
186
+ textAlignVertical="center"
187
+ // One line to start (a multiline field is two rows on react-native-web); it grows from there.
188
+ numberOfLines={1}
189
+ className={cn(
190
+ "min-h-8 flex-1 border-0 px-2 py-1 text-base font-instrument-sans",
191
+ largerText && "min-h-11 py-2 text-xl font-instrument-sans-medium",
192
+ )}
193
+ style={[
194
+ fieldTextStyle(scheme),
195
+ largerText ? typeStyle("medium") : null,
196
+ {
197
+ backgroundColor: "transparent",
198
+ maxHeight: largerText ? MAX_FIELD_HEIGHT.larger : MAX_FIELD_HEIGHT.regular,
199
+ },
200
+ ]}
201
+ />
202
+ <View className="shrink-0 flex-row items-center gap-1">
203
+ {toolbar}
204
+ <PromptInputSubmit status={status} onSubmit={submit} onStop={onStop} canSubmit={canSubmit} />
205
+ </View>
206
+ </View>
207
+ </View>
208
+ ) : (
209
+ <View className="border" style={fieldStyle(scheme)}>
210
+ {header ? <View className="flex-row flex-wrap gap-1 px-2 pt-2">{header}</View> : null}
211
+ {/* The `Textarea` field without its `numberOfLines` (default 4), which on Android sets the line count: this one starts at one line and grows. */}
212
+ <FieldInput
213
+ multiline
214
+ value={text}
215
+ onChangeText={setText}
216
+ placeholder={placeholder}
217
+ accessibilityLabel={accessibilityLabel}
218
+ editable={!disabled}
219
+ textAlignVertical="top"
220
+ className={cn(
221
+ "min-h-11 border-0 px-3.5 py-2.5 text-base font-instrument-sans",
222
+ largerText && "min-h-14 px-4 py-3 text-xl font-instrument-sans-medium",
223
+ )}
224
+ style={[
225
+ fieldTextStyle(scheme),
226
+ largerText ? typeStyle("medium") : null,
227
+ {
228
+ backgroundColor: "transparent",
229
+ maxHeight: largerText ? MAX_FIELD_HEIGHT.larger : MAX_FIELD_HEIGHT.regular,
230
+ },
231
+ ]}
232
+ />
233
+ <View className="flex-row items-center justify-between gap-1 px-2 pb-2">
234
+ <View className="min-w-0 shrink flex-row items-center gap-1">{toolbar}</View>
235
+ <PromptInputSubmit status={status} onSubmit={submit} onStop={onStop} canSubmit={canSubmit} />
236
+ </View>
186
237
  </View>
187
- </View>
238
+ )}
188
239
  </View>
189
240
  </KeyboardAvoidingView>
190
241
  );