@vvedantb/native-ui 0.3.17 → 0.5.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 (43) hide show
  1. package/README.md +146 -13
  2. package/package.json +10 -2
  3. package/src/CaptureButton.tsx +62 -0
  4. package/src/Card.tsx +37 -13
  5. package/src/ColorSwatches.tsx +133 -0
  6. package/src/DateTimeField.tsx +75 -19
  7. package/src/HeaderIconButton.tsx +18 -1
  8. package/src/IconButton.tsx +87 -0
  9. package/src/ListRow.tsx +104 -30
  10. package/src/PageIndicator.tsx +87 -0
  11. package/src/SectionLabel.tsx +48 -0
  12. package/src/SheetRow.tsx +89 -0
  13. package/src/StackPageHeader.tsx +100 -0
  14. package/src/StatGrid.tsx +80 -0
  15. package/src/StatusDot.tsx +14 -1
  16. package/src/ThemeToggle.tsx +17 -5
  17. package/src/ai/activity-step.tsx +57 -0
  18. package/src/ai/attachments.tsx +153 -0
  19. package/src/ai/chain-of-thought.tsx +143 -0
  20. package/src/ai/chat-text.ts +35 -0
  21. package/src/ai/code-block.tsx +104 -0
  22. package/src/ai/collapsible.tsx +111 -0
  23. package/src/ai/conversation.tsx +245 -0
  24. package/src/ai/file-chip.tsx +66 -0
  25. package/src/ai/icon-button.tsx +51 -0
  26. package/src/ai/index.ts +123 -0
  27. package/src/ai/message.tsx +146 -0
  28. package/src/ai/prompt-input.tsx +186 -0
  29. package/src/ai/reasoning.tsx +200 -0
  30. package/src/ai/response-text.tsx +56 -0
  31. package/src/ai/shimmer.tsx +86 -0
  32. package/src/ai/sources.tsx +100 -0
  33. package/src/ai/suggestion.tsx +93 -0
  34. package/src/ai/trace.tsx +234 -0
  35. package/src/ai/use-streaming-disclosure.ts +50 -0
  36. package/src/entries/ai.ts +4 -0
  37. package/src/entries/date-time.ts +1 -0
  38. package/src/entries/layout.ts +6 -2
  39. package/src/entries/page-title.ts +7 -2
  40. package/src/entries/primitives.ts +19 -1
  41. package/src/examples/AiChatGallery.tsx +233 -0
  42. package/src/examples/ParityGallery.tsx +205 -3
  43. package/src/index.ts +19 -0
@@ -0,0 +1,100 @@
1
+ import { useState, type ReactNode } from "react";
2
+ import { Text, View } from "react-native";
3
+ import { IconChevronLeft } from "@tabler/icons-react-native";
4
+ import { useSafeAreaInsets } from "react-native-safe-area-context";
5
+ import { cn } from "./cn";
6
+ import { PAGE_HEADER_HEIGHT, sizeStyle, textStyle } from "./controlStyles";
7
+ import { HeaderIconButton } from "./HeaderIconButton";
8
+ import { PageTitle } from "./PageTitle";
9
+ import { useLargerText } from "./theme";
10
+ import { tokens } from "./tokens";
11
+ import { useScheme } from "./useScheme";
12
+
13
+ export type StackPageHeaderProps = {
14
+ title: string;
15
+ /** Muted line under the title, up to two lines. */
16
+ description?: string;
17
+ /** Shows a back `HeaderIconButton` at the start that calls this. No button without it. */
18
+ onBack?: () => void;
19
+ /** Names the back button. Default "Back". */
20
+ backLabel?: string;
21
+ /** Actions at the end, such as a `HeaderIconButton`. */
22
+ headerRight?: ReactNode;
23
+ backTestID?: string;
24
+ /**
25
+ * Pad the top by the safe-area inset. Default `true`. Pass `false` under a
26
+ * native header or inside a view that already pads the inset.
27
+ */
28
+ safeTop?: boolean;
29
+ testID?: string;
30
+ className?: string;
31
+ };
32
+
33
+ /**
34
+ * A stack screen header with no fill, for a screen over `AmbientBackground`:
35
+ * a back chevron `HeaderIconButton` at the start, `headerRight` at the end and
36
+ * a centred `PageTitle` (Instrument Sans 600 at 20 / 25) with an optional
37
+ * muted description. The two sides are the same width, the wider of the back
38
+ * button and `headerRight`, so the title stays centred on the screen and a
39
+ * long one is cut off before it reaches them. The row is at least 56 high
40
+ * below the top inset, as `PageHeader`, with the `px-4` screen padding.
41
+ */
42
+ export function StackPageHeader({
43
+ title,
44
+ description,
45
+ onBack,
46
+ backLabel = "Back",
47
+ headerRight,
48
+ backTestID,
49
+ safeTop = true,
50
+ testID,
51
+ className,
52
+ }: StackPageHeaderProps) {
53
+ const insets = useSafeAreaInsets();
54
+ const scheme = useScheme();
55
+ const largerText = useLargerText();
56
+ const [rightWidth, setRightWidth] = useState(0);
57
+ const top = safeTop ? insets.top : 0;
58
+ // `HeaderIconButton` is 48 across (56 with larger text).
59
+ const backWidth = onBack ? (largerText ? 56 : 48) : 0;
60
+ const side = { minWidth: Math.max(backWidth, headerRight != null ? rightWidth : 0) };
61
+ const descriptionSize = largerText ? "text-base" : "text-sm";
62
+
63
+ return (
64
+ <View
65
+ testID={testID}
66
+ className={cn("flex-row items-center gap-2 px-4", className)}
67
+ style={{ paddingTop: top, minHeight: top + PAGE_HEADER_HEIGHT }}
68
+ >
69
+ <View className="flex-1 flex-row items-center justify-start" style={side}>
70
+ {onBack ? (
71
+ <HeaderIconButton label={backLabel} testID={backTestID} onPress={onBack}>
72
+ <IconChevronLeft size={largerText ? 26 : 22} color={tokens.color.foreground[scheme]} />
73
+ </HeaderIconButton>
74
+ ) : null}
75
+ </View>
76
+ <View className="min-w-0 shrink items-center py-2">
77
+ <PageTitle className="text-center">{title}</PageTitle>
78
+ {description ? (
79
+ <Text
80
+ numberOfLines={2}
81
+ className={cn("text-center font-instrument-sans", descriptionSize)}
82
+ style={[textStyle("muted", scheme), sizeStyle(descriptionSize)]}
83
+ >
84
+ {description}
85
+ </Text>
86
+ ) : null}
87
+ </View>
88
+ <View className="flex-1 flex-row items-center justify-end" style={side}>
89
+ {headerRight != null ? (
90
+ <View
91
+ className="flex-row items-center gap-2"
92
+ onLayout={(event) => setRightWidth(event.nativeEvent.layout.width)}
93
+ >
94
+ {headerRight}
95
+ </View>
96
+ ) : null}
97
+ </View>
98
+ </View>
99
+ );
100
+ }
@@ -0,0 +1,80 @@
1
+ import { Text, View } from "react-native";
2
+ import { cn } from "./cn";
3
+ import { cardStyle, sizeStyle, textStyle, typeStyle } from "./controlStyles";
4
+ import { useLargerText } from "./theme";
5
+ import { useScheme } from "./useScheme";
6
+
7
+ export type StatGridItem = { label: string; value: string; testID?: string };
8
+
9
+ export type StatGridProps = {
10
+ items: StatGridItem[];
11
+ /** Tiles per row. Default 2. */
12
+ columns?: 2 | 3;
13
+ testID?: string;
14
+ };
15
+
16
+ /** `items` in rows of `columns`. The last row is padded with `null`, so every tile has the same width. */
17
+ export function statGridRows<T>(items: T[], columns: number): (T | null)[][] {
18
+ const rows: (T | null)[][] = [];
19
+ for (let start = 0; start < items.length; start += columns) {
20
+ const row: (T | null)[] = items.slice(start, start + columns);
21
+ while (row.length < columns) row.push(null);
22
+ rows.push(row);
23
+ }
24
+ return rows;
25
+ }
26
+
27
+ /**
28
+ * Label and value tiles in rows of 2 or 3, each the flat web card
29
+ * (`cardStyle`: `surfaceCard` fill, radius 16, no shadow) with the web
30
+ * `Surface` padding (`p-4`). The label is muted `text-sm`; the value is
31
+ * semibold `text-xl` in the foreground, with tabular figures. VoiceOver and
32
+ * TalkBack read each tile as one element: "Steps, 8,432".
33
+ */
34
+ export function StatGrid({ items, columns = 2, testID }: StatGridProps) {
35
+ const scheme = useScheme();
36
+ const largerText = useLargerText();
37
+ if (items.length === 0) return null;
38
+ const labelSize = largerText ? "text-base" : "text-sm";
39
+ const valueSize = largerText ? "text-2xl" : "text-xl";
40
+ return (
41
+ <View testID={testID} className="gap-3">
42
+ {statGridRows(items, columns).map((row, rowIndex) => (
43
+ <View key={rowIndex} className="flex-row gap-3">
44
+ {row.map((item, index) =>
45
+ item ? (
46
+ <View
47
+ key={index}
48
+ testID={item.testID}
49
+ accessible
50
+ accessibilityLabel={`${item.label}, ${item.value}`}
51
+ className="min-w-0 flex-1 gap-1 p-4"
52
+ style={cardStyle(scheme)}
53
+ >
54
+ <Text
55
+ className={cn("font-instrument-sans", labelSize)}
56
+ style={[textStyle("muted", scheme), sizeStyle(labelSize)]}
57
+ >
58
+ {item.label}
59
+ </Text>
60
+ <Text
61
+ className={cn("font-instrument-sans-semibold", valueSize)}
62
+ style={[
63
+ textStyle("foreground", scheme),
64
+ sizeStyle(valueSize, "tight"),
65
+ typeStyle("semibold"),
66
+ { fontVariant: ["tabular-nums"] },
67
+ ]}
68
+ >
69
+ {item.value}
70
+ </Text>
71
+ </View>
72
+ ) : (
73
+ <View key={index} className="flex-1" />
74
+ ),
75
+ )}
76
+ </View>
77
+ ))}
78
+ </View>
79
+ );
80
+ }
package/src/StatusDot.tsx CHANGED
@@ -4,7 +4,10 @@ import { palette, withAlpha } from "./palette";
4
4
  import { tokens } from "./tokens";
5
5
  import { useScheme } from "./useScheme";
6
6
 
7
- /** The web `StatusTone`s. */
7
+ /**
8
+ * The web `StatusTone`s, and `info` (native only, from 0.5.0): neutral news
9
+ * such as "synced" or "new", in blue.
10
+ */
8
11
  export type StatusTone =
9
12
  | "progress"
10
13
  | "business-review"
@@ -13,6 +16,7 @@ export type StatusTone =
13
16
  | "cancelled"
14
17
  | "active"
15
18
  | "critical"
19
+ | "info"
16
20
  | "neutral";
17
21
 
18
22
  export type StatusDotProps = ViewProps & {
@@ -22,6 +26,13 @@ export type StatusDotProps = ViewProps & {
22
26
  className?: string;
23
27
  };
24
28
 
29
+ /**
30
+ * The `info` blue: Tailwind `blue-500` in light and the lighter `blue-400` in
31
+ * dark. Local to the dot: `tokens.css` has no `--info`, so it is not a
32
+ * `palette` key.
33
+ */
34
+ const INFO = { light: "#3b82f6", dark: "#60a5fa" } as const;
35
+
25
36
  function toneColor(tone: StatusTone, scheme: "light" | "dark") {
26
37
  switch (tone) {
27
38
  case "progress":
@@ -37,6 +48,8 @@ function toneColor(tone: StatusTone, scheme: "light" | "dark") {
37
48
  return withAlpha(tokens.color.muted[scheme], 0.4);
38
49
  case "critical":
39
50
  return palette.danger[scheme];
51
+ case "info":
52
+ return INFO[scheme];
40
53
  default:
41
54
  return withAlpha(tokens.color.muted[scheme], 0.6);
42
55
  }
@@ -9,6 +9,10 @@ export type ThemeToggleProps = {
9
9
  testID?: string;
10
10
  };
11
11
 
12
+ /** Logged when NativeWind's `setColorScheme` throws. */
13
+ const DARK_MODE_CLASS_HINT =
14
+ 'ThemeToggle could not set the colour scheme. NativeWind throws while Tailwind darkMode is "media" (the default): set darkMode: "class" in tailwind.config.js. See the @vvedantb/native-ui README, Theme.';
15
+
12
16
  /**
13
17
  * A `HeaderIconButton` that switches between light and dark with NativeWind's
14
18
  * `setColorScheme`, the scheme `useScheme` reads. It shows and names the
@@ -16,6 +20,10 @@ export type ThemeToggleProps = {
16
20
  * "Switch to light mode" in dark. The choice lasts only as long as NativeWind
17
21
  * keeps it; an app that restores it on the next launch stores
18
22
  * `useColorScheme().colorScheme` itself.
23
+ *
24
+ * `setColorScheme` throws unless the app sets Tailwind `darkMode: "class"`.
25
+ * The press then logs that fix with `console.error` and leaves the scheme as
26
+ * it is, instead of throwing from the press handler.
19
27
  */
20
28
  export function ThemeToggle({ testID }: ThemeToggleProps) {
21
29
  const { setColorScheme } = useColorScheme();
@@ -24,12 +32,16 @@ export function ThemeToggle({ testID }: ThemeToggleProps) {
24
32
  const next = scheme === "dark" ? "light" : "dark";
25
33
  const Icon = next === "dark" ? IconMoon : IconSun;
26
34
 
35
+ const toggle = () => {
36
+ try {
37
+ setColorScheme(next);
38
+ } catch (error) {
39
+ console.error(DARK_MODE_CLASS_HINT, error);
40
+ }
41
+ };
42
+
27
43
  return (
28
- <HeaderIconButton
29
- label={`Switch to ${next} mode`}
30
- testID={testID}
31
- onPress={() => setColorScheme(next)}
32
- >
44
+ <HeaderIconButton label={`Switch to ${next} mode`} testID={testID} onPress={toggle}>
33
45
  <Icon size={largerText ? 26 : 22} color={tokens.color.foreground[scheme]} />
34
46
  </HeaderIconButton>
35
47
  );
@@ -0,0 +1,57 @@
1
+ import type { ComponentType } from "react";
2
+ import { IconAlertTriangle, IconCheck, type IconProps } from "@tabler/icons-react-native";
3
+ import { Spinner } from "../Spinner";
4
+ import { useLargerText } from "../theme";
5
+ import { AgentTraceDescription, AgentTraceIcon, AgentTraceLine, AgentTraceTitle } from "./trace";
6
+
7
+ export type ActivityStepStatus = "running" | "complete" | "error";
8
+
9
+ export type ActivityStepProps = {
10
+ /** What the AI is doing, such as "Searching the web" or "Reading 3 files". */
11
+ label: string;
12
+ /** The tool's icon. Without one, the line shows the status icon only. */
13
+ icon?: ComponentType<IconProps>;
14
+ /** `running` pulses the label and shows a spinner; `complete` a check; `error` a warning in the danger colour. Default `complete`. */
15
+ status?: ActivityStepStatus;
16
+ /** Faded detail after the label, such as "Worked for 12s" (see `formatElapsed`). */
17
+ description?: string;
18
+ testID?: string;
19
+ };
20
+
21
+ /**
22
+ * One tool or activity step as a single trace line: icon, label, status and
23
+ * an optional description. A light stand-in for the web `ActivityTasks`, built
24
+ * on the `AgentTrace` parts. Its accessibility label reads the label, the
25
+ * status and the description.
26
+ */
27
+ export function ActivityStep({
28
+ label,
29
+ icon,
30
+ status = "complete",
31
+ description,
32
+ testID,
33
+ }: ActivityStepProps) {
34
+ const largerText = useLargerText();
35
+ const running = status === "running";
36
+ const state = running ? "in progress" : status === "error" ? "failed" : "done";
37
+ return (
38
+ <AgentTraceLine
39
+ testID={testID}
40
+ status={status === "error" ? "error" : undefined}
41
+ accessible
42
+ accessibilityLabel={[label, state, description].filter(Boolean).join(", ")}
43
+ accessibilityState={{ busy: running }}
44
+ >
45
+ {icon ? <AgentTraceIcon icon={icon} /> : null}
46
+ <AgentTraceTitle shimmer={running}>{label}</AgentTraceTitle>
47
+ {description ? <AgentTraceDescription>{description}</AgentTraceDescription> : null}
48
+ {running ? (
49
+ <AgentTraceIcon>
50
+ <Spinner size={largerText ? "md" : "sm"} decorative />
51
+ </AgentTraceIcon>
52
+ ) : (
53
+ <AgentTraceIcon icon={status === "error" ? IconAlertTriangle : IconCheck} />
54
+ )}
55
+ </AgentTraceLine>
56
+ );
57
+ }
@@ -0,0 +1,153 @@
1
+ import { createContext, useContext, type ComponentType } from "react";
2
+ import { Image, Pressable, View, type ViewProps } from "react-native";
3
+ import {
4
+ IconFileText,
5
+ IconMusic,
6
+ IconPaperclip,
7
+ IconPhoto,
8
+ IconVideo,
9
+ IconWorld,
10
+ IconX,
11
+ type IconProps,
12
+ } from "@tabler/icons-react-native";
13
+ import { cn } from "../cn";
14
+ import { touchSlop } from "../controlStyles";
15
+ import { useLargerText } from "../theme";
16
+ import { tokens } from "../tokens";
17
+ import { useScheme } from "../useScheme";
18
+ import { ResponseText } from "./response-text";
19
+
20
+ /**
21
+ * A file or source attached to a prompt or message. The fields the web reads
22
+ * from the `ai` SDK `FileUIPart` and `SourceDocumentUIPart`, without the SDK.
23
+ */
24
+ export type AttachmentData = {
25
+ id: string;
26
+ /** `"source-document"` for a referenced source. Default a file. */
27
+ type?: "file" | "source-document";
28
+ filename?: string;
29
+ /** MIME type, such as `image/png`. */
30
+ mediaType?: string;
31
+ /** A local or remote URI. An image with one shows a thumbnail. */
32
+ url?: string;
33
+ /** A source document's title. */
34
+ title?: string;
35
+ };
36
+
37
+ export type AttachmentMediaCategory = "image" | "video" | "audio" | "document" | "source" | "unknown";
38
+
39
+ const MEDIA_ICONS: Record<AttachmentMediaCategory, ComponentType<IconProps>> = {
40
+ audio: IconMusic,
41
+ document: IconFileText,
42
+ image: IconPhoto,
43
+ source: IconWorld,
44
+ unknown: IconPaperclip,
45
+ video: IconVideo,
46
+ };
47
+
48
+ /** The kind of attachment, from its type and MIME type, as on the web. */
49
+ export function getMediaCategory(data: AttachmentData): AttachmentMediaCategory {
50
+ if (data.type === "source-document") return "source";
51
+ const mediaType = data.mediaType ?? "";
52
+ if (mediaType.startsWith("image/")) return "image";
53
+ if (mediaType.startsWith("video/")) return "video";
54
+ if (mediaType.startsWith("audio/")) return "audio";
55
+ if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) return "document";
56
+ return "unknown";
57
+ }
58
+
59
+ /** The name to show and read, as on the web: the title or file name, else "Source", "Image" or "Attachment". */
60
+ export function getAttachmentLabel(data: AttachmentData): string {
61
+ if (data.type === "source-document") return data.title || data.filename || "Source";
62
+ return data.filename || (getMediaCategory(data) === "image" ? "Image" : "Attachment");
63
+ }
64
+
65
+ /** `inline` (default) wraps chips in rows; `list` stacks them full width. The web `grid` previews are not ported. */
66
+ export type AttachmentVariant = "inline" | "list";
67
+
68
+ const AttachmentsContext = createContext<AttachmentVariant>("inline");
69
+
70
+ export type AttachmentsProps = ViewProps & {
71
+ variant?: AttachmentVariant;
72
+ className?: string;
73
+ };
74
+
75
+ /** The attachments of a prompt or message. */
76
+ export function Attachments({ variant = "inline", className, ...props }: AttachmentsProps) {
77
+ return (
78
+ <AttachmentsContext.Provider value={variant}>
79
+ <View
80
+ className={cn(variant === "list" ? "gap-2" : "flex-row flex-wrap items-start gap-2", className)}
81
+ {...props}
82
+ />
83
+ </AttachmentsContext.Provider>
84
+ );
85
+ }
86
+
87
+ export type AttachmentProps = {
88
+ data: AttachmentData;
89
+ /** Shows a remove button, labelled "Remove" and the attachment name. */
90
+ onRemove?: () => void;
91
+ testID?: string;
92
+ };
93
+
94
+ /** A remove cross of 20 in a 32 row: the slop brings it to 44. */
95
+ const REMOVE_SIZE = 20;
96
+
97
+ /**
98
+ * One attachment as a chip: a 20 thumbnail for an image with a `url`, else
99
+ * its media icon, then the name and an optional remove button. Flat, on the
100
+ * web `flatPanelSubtle` fill (`bg-surface-secondary`). No hover card.
101
+ */
102
+ export function Attachment({ data, onRemove, testID }: AttachmentProps) {
103
+ const variant = useContext(AttachmentsContext);
104
+ const scheme = useScheme();
105
+ const largerText = useLargerText();
106
+ const category = getMediaCategory(data);
107
+ const label = getAttachmentLabel(data);
108
+ const Icon = MEDIA_ICONS[category];
109
+ const iconSize = largerText ? 20 : 16;
110
+ const muted = tokens.color.muted[scheme];
111
+ return (
112
+ <View
113
+ testID={testID}
114
+ className={cn(
115
+ "min-w-0 flex-row items-center gap-1.5 px-2",
116
+ largerText ? "min-h-11" : "min-h-8",
117
+ variant === "list" ? "w-full" : "max-w-full",
118
+ )}
119
+ style={{ borderRadius: tokens.radius.sm, backgroundColor: tokens.color.surfaceSecondary[scheme] }}
120
+ >
121
+ {category === "image" && data.url ? (
122
+ <Image
123
+ source={{ uri: data.url }}
124
+ resizeMode="cover"
125
+ style={{ width: 20, height: 20, borderRadius: 4 }}
126
+ />
127
+ ) : (
128
+ <Icon size={iconSize} color={muted} />
129
+ )}
130
+ <ResponseText
131
+ size="small"
132
+ selectable={false}
133
+ numberOfLines={1}
134
+ ellipsizeMode="middle"
135
+ className={variant === "list" ? "flex-1" : "shrink"}
136
+ >
137
+ {label}
138
+ </ResponseText>
139
+ {onRemove ? (
140
+ <Pressable
141
+ accessibilityRole="button"
142
+ accessibilityLabel={`Remove ${label}`}
143
+ hitSlop={touchSlop(REMOVE_SIZE)}
144
+ onPress={onRemove}
145
+ className="items-center justify-center rounded-full active:opacity-70"
146
+ style={{ width: REMOVE_SIZE, height: REMOVE_SIZE }}
147
+ >
148
+ <IconX size={largerText ? 16 : 14} color={muted} />
149
+ </Pressable>
150
+ ) : null}
151
+ </View>
152
+ );
153
+ }
@@ -0,0 +1,143 @@
1
+ import type { ComponentType, ReactNode } from "react";
2
+ import { Text, View, type ViewProps } from "react-native";
3
+ import { IconBrain, IconPointFilled, type IconProps } from "@tabler/icons-react-native";
4
+ import { badgeColors, Badge } from "../Badge";
5
+ import { cn } from "../cn";
6
+ import { sizeStyle } from "../controlStyles";
7
+ import { useLargerText } from "../theme";
8
+ import { tokens } from "../tokens";
9
+ import { useScheme } from "../useScheme";
10
+ import { chatTextSize } from "./chat-text";
11
+ import {
12
+ Collapsible,
13
+ CollapsibleChevron,
14
+ CollapsibleContent,
15
+ CollapsibleTrigger,
16
+ useCollapsible,
17
+ type CollapsibleProps,
18
+ type CollapsibleTriggerProps,
19
+ } from "./collapsible";
20
+ import { ResponseText } from "./response-text";
21
+
22
+ // The web `ChainOfThought`, simplified: a fold of steps with search-result
23
+ // chips. `ChainOfThoughtImage` and the step enter animation are not ported.
24
+
25
+ /** Holds its open state (default closed), or takes `open` and `onOpenChange`. */
26
+ export function ChainOfThought({ className, ...props }: CollapsibleProps) {
27
+ return <Collapsible className={cn("w-full gap-4", className)} {...props} />;
28
+ }
29
+
30
+ export type ChainOfThoughtHeaderProps = Omit<CollapsibleTriggerProps, "children"> & {
31
+ /** Default "Chain of Thought". A string, as it is also the accessibility label. */
32
+ children?: string;
33
+ icon?: ComponentType<IconProps>;
34
+ };
35
+
36
+ /** Brain icon, title and chevron: toggles the steps. */
37
+ export function ChainOfThoughtHeader({
38
+ children = "Chain of Thought",
39
+ icon: Icon = IconBrain,
40
+ className,
41
+ ...props
42
+ }: ChainOfThoughtHeaderProps) {
43
+ const { open } = useCollapsible();
44
+ const scheme = useScheme();
45
+ const largerText = useLargerText();
46
+ const muted = tokens.color.muted[scheme];
47
+ const iconSize = largerText ? 20 : 16;
48
+ return (
49
+ <CollapsibleTrigger
50
+ accessibilityLabel={children}
51
+ className={cn("w-full flex-row items-center gap-2", className)}
52
+ {...props}
53
+ >
54
+ <Icon size={iconSize} color={muted} />
55
+ <ResponseText size="small" tone="muted" selectable={false} className="flex-1">
56
+ {children}
57
+ </ResponseText>
58
+ <CollapsibleChevron open={open} size={iconSize} color={muted} />
59
+ </CollapsibleTrigger>
60
+ );
61
+ }
62
+
63
+ /** The steps, shown while open. */
64
+ export function ChainOfThoughtContent({ className, ...props }: ViewProps & { className?: string }) {
65
+ return <CollapsibleContent className={cn("gap-3", className)} {...props} />;
66
+ }
67
+
68
+ export type ChainOfThoughtStepStatus = "complete" | "active" | "pending";
69
+
70
+ export type ChainOfThoughtStepProps = ViewProps & {
71
+ icon?: ComponentType<IconProps>;
72
+ label: string;
73
+ description?: string;
74
+ /** `active` is the foreground, `complete` muted, `pending` muted at half opacity, as on the web. Default `complete`. */
75
+ status?: ChainOfThoughtStepStatus;
76
+ className?: string;
77
+ children?: ReactNode;
78
+ };
79
+
80
+ /** One step: icon with a rail under it to the next step, label, description and any children (such as search results). */
81
+ export function ChainOfThoughtStep({
82
+ icon: Icon = IconPointFilled,
83
+ label,
84
+ description,
85
+ status = "complete",
86
+ className,
87
+ style,
88
+ children,
89
+ ...props
90
+ }: ChainOfThoughtStepProps) {
91
+ const scheme = useScheme();
92
+ const largerText = useLargerText();
93
+ const tone = status === "active" ? "foreground" : "muted";
94
+ return (
95
+ <View
96
+ className={cn("flex-row gap-2", className)}
97
+ style={[status === "pending" ? { opacity: 0.5 } : null, style]}
98
+ {...props}
99
+ >
100
+ <View className="mt-0.5 items-center">
101
+ <Icon size={largerText ? 20 : 16} color={tokens.color[tone][scheme]} />
102
+ <View
103
+ className="mt-2 w-px flex-1"
104
+ style={{ backgroundColor: tokens.color.separator[scheme] }}
105
+ />
106
+ </View>
107
+ <View className="min-w-0 flex-1 gap-2">
108
+ <ResponseText size="small" tone={tone}>
109
+ {label}
110
+ </ResponseText>
111
+ {description ? (
112
+ <ResponseText size="caption" tone="muted">
113
+ {description}
114
+ </ResponseText>
115
+ ) : null}
116
+ {children}
117
+ </View>
118
+ </View>
119
+ );
120
+ }
121
+
122
+ /** A wrapping row of `ChainOfThoughtSearchResult` chips. */
123
+ export function ChainOfThoughtSearchResults({ className, ...props }: ViewProps & { className?: string }) {
124
+ return <View className={cn("flex-row flex-wrap items-center gap-2", className)} {...props} />;
125
+ }
126
+
127
+ /** One search result: a `secondary` `Badge` with regular `text-xs` type, as the web `font-normal text-xs`. */
128
+ export function ChainOfThoughtSearchResult({ children, className }: { children: string; className?: string }) {
129
+ const scheme = useScheme();
130
+ const largerText = useLargerText();
131
+ const size = chatTextSize("caption", largerText);
132
+ return (
133
+ <Badge variant="secondary" className={cn("px-2 py-0.5", className)}>
134
+ <Text
135
+ numberOfLines={1}
136
+ className={cn(size, "font-instrument-sans")}
137
+ style={[sizeStyle(size), { color: badgeColors("secondary", scheme).color }]}
138
+ >
139
+ {children}
140
+ </Text>
141
+ </Badge>
142
+ );
143
+ }
@@ -0,0 +1,35 @@
1
+ /** Type and copy shared by the AI chat parts. */
2
+ import { Platform } from "react-native";
3
+ import type { TextSize } from "../controlStyles";
4
+
5
+ export type ChatTextKind = "body" | "small" | "caption";
6
+
7
+ /**
8
+ * `body` is message text: `text-base` (16 / 24), the phone body size (the web
9
+ * `MessageContent` is `text-sm`, but the web composer is 16 on phones).
10
+ * `small` is the secondary text of triggers, trace lines and sources:
11
+ * `text-sm` (14 / 20; the web sources trigger is `text-xs`). `caption` is
12
+ * `text-xs` (12 / 16): step descriptions, file chips and code headers. Larger
13
+ * text steps each up, as `Textarea` does.
14
+ */
15
+ export function chatTextSize(kind: ChatTextKind, largerText: boolean): TextSize {
16
+ if (kind === "body") return largerText ? "text-xl" : "text-base";
17
+ if (kind === "small") return largerText ? "text-lg" : "text-sm";
18
+ return largerText ? "text-base" : "text-xs";
19
+ }
20
+
21
+ /** The system monospace face, for code and reasoning text (web `font-mono`). */
22
+ export const MONO_FONT = Platform.select({ ios: "Menlo", default: "monospace" });
23
+
24
+ /**
25
+ * Elapsed time for "Thought for 12s" and "Worked for 1m 5s": whole seconds
26
+ * under a minute, minutes and seconds above. Negative and fractional values
27
+ * round to whole seconds, from 0.
28
+ */
29
+ export function formatElapsed(seconds: number): string {
30
+ const total = Math.max(0, Math.round(seconds));
31
+ if (total < 60) return `${total}s`;
32
+ const minutes = Math.floor(total / 60);
33
+ const rest = total % 60;
34
+ return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
35
+ }