@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
@@ -0,0 +1,107 @@
1
+ // Pure step types for the activity timeline: no React, so `@vvedantb/native-ui`
2
+ // keeps a byte-identical copy (`src/ai/activity-step-types.ts`), with the step
3
+ // labels and grouping that read them.
4
+
5
+ /** One item in a todo checklist step (type "todos"). */
6
+ export interface ActivityTodoItem {
7
+ content: string;
8
+ status: "pending" | "in_progress" | "completed";
9
+ }
10
+
11
+ export interface ActivityStepOutput {
12
+ text: string;
13
+ exitCode?: number;
14
+ truncated?: boolean;
15
+ }
16
+
17
+ export interface ActivityStepEdit {
18
+ oldText: string;
19
+ newText: string;
20
+ }
21
+
22
+ /** One choice offered by an AskUserQuestion prompt. */
23
+ export interface ActivityQuestionOption {
24
+ label: string;
25
+ description?: string;
26
+ }
27
+
28
+ /** One question of an AskUserQuestion prompt, with the choices shown. */
29
+ export interface ActivityQuestion {
30
+ question: string;
31
+ header?: string;
32
+ multiSelect?: boolean;
33
+ options: ActivityQuestionOption[];
34
+ }
35
+
36
+ export interface ActivityStep {
37
+ type:
38
+ | "read"
39
+ | "edit"
40
+ | "write"
41
+ | "bash"
42
+ | "search_files"
43
+ | "search_code"
44
+ | "web_fetch"
45
+ | "web_search"
46
+ | "subtask"
47
+ | "notebook"
48
+ | "thinking"
49
+ | "reasoning"
50
+ | "response"
51
+ | "question"
52
+ | "todos"
53
+ | "tool"
54
+ | "notice"
55
+ | "hook"
56
+ | "status"
57
+ /** A queued user message the agent took into this live turn (Steer). */
58
+ | "steer";
59
+ label: string;
60
+ detail?: string;
61
+ /** Full, unshortened path for file-type steps. Powers the chat File Viewer. */
62
+ path?: string;
63
+ /** `pending`: a planned step that has not started (sandbox startup stages). */
64
+ status: "complete" | "active" | "pending";
65
+ /** The tool_use id that produced this step (Claude only). */
66
+ toolUseId?: string;
67
+ /** Parent `Agent` tool_use id — set on steps that ran inside a subagent. */
68
+ parentToolUseId?: string;
69
+ /** Todo checklist snapshot (type "todos" only). */
70
+ todos?: ActivityTodoItem[];
71
+ /** Bash command (fuller than detail, capped). */
72
+ command?: string;
73
+ /** Tool result transcript (tail-capped). */
74
+ output?: ActivityStepOutput;
75
+ /** Edit before/after snippets. */
76
+ edits?: ActivityStepEdit[];
77
+ /** Codex file_change paths. */
78
+ files?: string[];
79
+ /** Write tool content head preview. */
80
+ contentPreview?: string;
81
+ /** True when the tool failed or exited non-zero. */
82
+ isError?: boolean;
83
+ /** Wall time from push → complete (ms). */
84
+ durationMs?: number;
85
+ /** AskUserQuestion prompt (type "question" only): the questions and options shown to the user. */
86
+ questions?: ActivityQuestion[];
87
+ /** AskUserQuestion answers keyed by question text. Absent when the turn ended without a structured answer. */
88
+ answers?: Record<string, string>;
89
+ /**
90
+ * The transcript query stripped this step's expanded-only fields (`output`,
91
+ * `edits`, `contentPreview`) to keep the chat subscription small. The row
92
+ * still opens; the body fills in once the full payload is fetched.
93
+ */
94
+ hasHiddenDetail?: boolean;
95
+ }
96
+
97
+ /** True when the step has expandable rich detail to show. */
98
+ export function activityStepHasRichDetail(step: ActivityStep): boolean {
99
+ return Boolean(
100
+ step.command ||
101
+ step.output ||
102
+ (step.edits && step.edits.length > 0) ||
103
+ (step.files && step.files.length > 0) ||
104
+ step.contentPreview ||
105
+ step.hasHiddenDetail,
106
+ );
107
+ }
@@ -0,0 +1,199 @@
1
+ import type { ActivityStep } from "./activity-step-types";
2
+
3
+ /**
4
+ * One row in the activity timeline. Subtask rows may nest child rows that ran
5
+ * inside that agent (keyed by parentToolUseId → toolUseId).
6
+ */
7
+ export interface ActivityRow {
8
+ step: ActivityStep;
9
+ children?: ActivityRow[];
10
+ }
11
+
12
+ /**
13
+ * Status-filler thinking labels emitted by older callback versions (and by
14
+ * prod until the current callback deploys). The response/reasoning text is
15
+ * shown directly, so these narration rows are pure noise — hide them.
16
+ */
17
+ const LEGACY_NOISE_LABELS = new Set([
18
+ "Generating response...",
19
+ "Generated response",
20
+ "Streaming response...",
21
+ "Streamed response",
22
+ "Finalizing response...",
23
+ ]);
24
+
25
+ /** Thinking labels that mark model reasoning rather than status narration. */
26
+ const REASONING_LABELS = new Set(["Thinking...", "Thought"]);
27
+
28
+ /** Placeholder details older callbacks attached to reasoning-label steps. */
29
+ const FILLER_DETAILS = new Set([
30
+ "Claude is reasoning...",
31
+ "Codex is reasoning...",
32
+ "Opencode is reasoning...",
33
+ "Cursor is reasoning...",
34
+ "Receiving reply...",
35
+ ]);
36
+
37
+ /**
38
+ * Older callbacks stored reasoning as thinking steps labelled
39
+ * "Thinking..."/"Thought". Remap those with real text to reasoning blocks;
40
+ * drop the bare placeholder ones (nothing to show).
41
+ */
42
+ export function normalizeStep(step: ActivityStep): ActivityStep | null {
43
+ if (step.type !== "thinking") return step;
44
+ if (LEGACY_NOISE_LABELS.has(step.label)) return null;
45
+ if (REASONING_LABELS.has(step.label)) {
46
+ if (step.detail && !FILLER_DETAILS.has(step.detail)) {
47
+ return { ...step, type: "reasoning" };
48
+ }
49
+ return null;
50
+ }
51
+ return step;
52
+ }
53
+
54
+ /** Builds one row per normalized step (no consecutive-type merging). */
55
+ function stepsToRows(steps: ActivityStep[]): ActivityRow[] {
56
+ const rows: ActivityRow[] = [];
57
+ for (const raw of steps) {
58
+ const step = normalizeStep(raw);
59
+ if (!step) continue;
60
+ rows.push({ step });
61
+ }
62
+ return rows;
63
+ }
64
+
65
+ /**
66
+ * Row types that read as narration rather than work: they already summarise
67
+ * themselves, so folding them into an action group would hide their only text.
68
+ */
69
+ const STANDALONE_TYPES = new Set<ActivityStep["type"]>([
70
+ "todos",
71
+ "notice",
72
+ "status",
73
+ "question",
74
+ "steer",
75
+ ]);
76
+
77
+ /** One block in the activity timeline: prose, a lone row, or a folded run. */
78
+ export type ActivitySegment =
79
+ | { kind: "reasoning"; step: ActivityStep }
80
+ | { kind: "response"; step: ActivityStep }
81
+ | { kind: "row"; row: ActivityRow }
82
+ | { kind: "actions"; rows: ActivityRow[] };
83
+
84
+ /**
85
+ * Folds rows into the read-then-act rhythm of the turn: each reasoning block
86
+ * stays prose, and the run of tool calls it produced collapses behind a single
87
+ * summary line. Without this the whole turn is one undifferentiated wall of
88
+ * per-call rows and the thinking that motivated them is lost in it.
89
+ */
90
+ export function groupActivityRows(rows: ActivityRow[]): ActivitySegment[] {
91
+ const segments: ActivitySegment[] = [];
92
+
93
+ for (const row of rows) {
94
+ if (row.step.type === "reasoning") {
95
+ segments.push({ kind: "reasoning", step: row.step });
96
+ continue;
97
+ }
98
+ // Mid-turn reply text: it ends the tool run before it, like reasoning.
99
+ if (row.step.type === "response") {
100
+ segments.push({ kind: "response", step: row.step });
101
+ continue;
102
+ }
103
+ if (STANDALONE_TYPES.has(row.step.type)) {
104
+ segments.push({ kind: "row", row });
105
+ continue;
106
+ }
107
+ const last = segments.at(-1);
108
+ if (last?.kind === "actions") {
109
+ last.rows.push(row);
110
+ continue;
111
+ }
112
+ segments.push({ kind: "actions", rows: [row] });
113
+ }
114
+
115
+ return segments;
116
+ }
117
+
118
+ /**
119
+ * React key for one activity step. `label` and `status` are omitted so a
120
+ * streaming update (Running → Ran, error → recovered) keeps the same instance.
121
+ * `toolUseId` is the identity the callback already stamps; the fallback is
122
+ * type + path/command + occurrence in the list being keyed (never the visible
123
+ * overflow slice — callers must pass the full-list index).
124
+ */
125
+ export function activityStepKey(
126
+ step: ActivityStep,
127
+ occurrence: number,
128
+ ): string {
129
+ const id = step.toolUseId;
130
+ if (id !== undefined && id.length > 0) {
131
+ return `id:${id}`;
132
+ }
133
+ const stable = step.path ?? step.command ?? "";
134
+ return `fb:${step.type}:${stable}:${occurrence}`;
135
+ }
136
+
137
+ export function activityRowKey(row: ActivityRow, occurrence: number): string {
138
+ return activityStepKey(row.step, occurrence);
139
+ }
140
+
141
+ /** React key for a timeline segment. Action groups key off the first row. */
142
+ export function activitySegmentKey(
143
+ segment: ActivitySegment,
144
+ occurrence: number,
145
+ ): string {
146
+ if (segment.kind === "reasoning" || segment.kind === "response") {
147
+ return `${segment.kind}:${activityStepKey(segment.step, occurrence)}`;
148
+ }
149
+ if (segment.kind === "row") {
150
+ return `row:${activityRowKey(segment.row, occurrence)}`;
151
+ }
152
+ const first = segment.rows[0];
153
+ if (first === undefined) {
154
+ return `actions:${occurrence}`;
155
+ }
156
+ return `actions:${activityRowKey(first, occurrence)}`;
157
+ }
158
+
159
+ /**
160
+ * Builds per-call activity rows. Steps with `parentToolUseId` nest under the
161
+ * matching subtask `toolUseId`. Orphans whose parent was never found append
162
+ * at the top level (never drop).
163
+ */
164
+ export function buildActivityRows(steps: ActivityStep[]): ActivityRow[] {
165
+ const childrenByParent = new Map<string, ActivityStep[]>();
166
+ const topLevel: ActivityStep[] = [];
167
+
168
+ for (const step of steps) {
169
+ const parentId = step.parentToolUseId;
170
+ if (parentId) {
171
+ const existing = childrenByParent.get(parentId);
172
+ if (existing) existing.push(step);
173
+ else childrenByParent.set(parentId, [step]);
174
+ } else {
175
+ topLevel.push(step);
176
+ }
177
+ }
178
+
179
+ const rows = stepsToRows(topLevel);
180
+ const attached = new Set<string>();
181
+
182
+ for (const row of rows) {
183
+ if (row.step.type !== "subtask") continue;
184
+ const toolUseId = row.step.toolUseId;
185
+ if (!toolUseId) continue;
186
+ const kids = childrenByParent.get(toolUseId);
187
+ if (kids && kids.length > 0) {
188
+ row.children = stepsToRows(kids);
189
+ attached.add(toolUseId);
190
+ }
191
+ }
192
+
193
+ for (const [parentId, kids] of childrenByParent) {
194
+ if (attached.has(parentId)) continue;
195
+ rows.push(...stepsToRows(kids));
196
+ }
197
+
198
+ return rows;
199
+ }
@@ -0,0 +1,45 @@
1
+ // The folded (Codex-style) rules of the web `ActivityTasks`
2
+ // (`@vvedantb/ui` `activity-tasks.tsx`), as pure logic for an app that draws
3
+ // the rows with the `AgentTrace` parts. The row text, the grouping and the
4
+ // step types are byte-identical copies of the web files
5
+ // (`activity-step-label.ts`, `activity-tasks-utils.ts`, `activity-step-types.ts`).
6
+ import type { ActivityRow, ActivitySegment } from "./activity-tasks-utils";
7
+ import { buildActivityRows, groupActivityRows } from "./activity-tasks-utils";
8
+ import type { ActivityStep } from "./activity-step-types";
9
+
10
+ /** Blocks a settled timeline shows before "Show N more" (web `MAX_VISIBLE_ROWS`). */
11
+ export const ACTIVITY_MAX_VISIBLE_BLOCKS = 8;
12
+
13
+ /** Child rows a subagent step shows before "+N more" (web `MAX_VISIBLE_CHILDREN`). */
14
+ export const ACTIVITY_MAX_VISIBLE_CHILDREN = 8;
15
+
16
+ /** Step types the folded layout hides: the reply text and the status thinking lines. */
17
+ const HIDDEN_TYPES = new Set<ActivityStep["type"]>(["thinking", "response"]);
18
+
19
+ /**
20
+ * A turn's steps as the blocks of the folded timeline: each reasoning step as
21
+ * prose, each run of tool calls behind one summary line, narration (to-dos,
22
+ * notices, questions) as lone rows, and the steps a subagent ran nested under
23
+ * its row. Reply text and status thinking lines are dropped, as on the web.
24
+ */
25
+ export function foldedActivitySegments(steps: ActivityStep[]): ActivitySegment[] {
26
+ return groupActivityRows(buildActivityRows(steps).filter((row) => !HIDDEN_TYPES.has(row.step.type)));
27
+ }
28
+
29
+ /**
30
+ * The child rows a subagent row shows (its reply text belongs to its own
31
+ * thread, so it is dropped), capped at `ACTIVITY_MAX_VISIBLE_CHILDREN`, and
32
+ * how many are left for "+N more".
33
+ */
34
+ export function activityChildRows(row: ActivityRow): { shown: ActivityRow[]; total: number; overflow: number } {
35
+ const children = (row.children ?? []).filter((child) => !HIDDEN_TYPES.has(child.step.type));
36
+ const overflow = Math.max(0, children.length - ACTIVITY_MAX_VISIBLE_CHILDREN);
37
+ return { shown: children.slice(0, ACTIVITY_MAX_VISIBLE_CHILDREN), total: children.length, overflow };
38
+ }
39
+
40
+ /** Whole seconds a finished step took, for the faded "4s" after its row; undefined when unknown or under 1s. */
41
+ export function activityStepSeconds(step: ActivityStep): number | undefined {
42
+ if (step.status !== "complete" || !step.durationMs) return undefined;
43
+ const seconds = Math.round(step.durationMs / 1000);
44
+ return seconds >= 1 ? seconds : undefined;
45
+ }
@@ -175,7 +175,9 @@ export function Attachment({ data, onRemove, onPress, accessibilityLabel, testID
175
175
  <Pressable
176
176
  accessibilityRole="button"
177
177
  accessibilityLabel={`Remove ${label}`}
178
- hitSlop={touchSlop(REMOVE_SIZE)}
178
+ // A pressable label sits 6 before it, and the next chip 8 past this
179
+ // chip's edge: the slop stops short of both, so neither steals a tap.
180
+ hitSlop={{ ...touchSlop(REMOVE_SIZE), left: onPress ? 3 : touchSlop(REMOVE_SIZE).left, right: 8 }}
179
181
  onPress={onRemove}
180
182
  className="items-center justify-center rounded-full active:opacity-70"
181
183
  style={{ width: REMOVE_SIZE, height: REMOVE_SIZE }}
@@ -5,19 +5,25 @@ import type { TextSize } from "../controlStyles";
5
5
  export type ChatTextKind = "body" | "small" | "caption";
6
6
 
7
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.
8
+ * `body` is message text: `text-sm` (14 / 20), as the web `MessageContent`.
9
+ * `small` is the secondary text of triggers, trace lines and sources: also
10
+ * `text-sm` (the web sources trigger is `text-xs`). `caption` is `text-xs`
11
+ * (12 / 16): step descriptions, file chips and code headers. Larger text
12
+ * steps each up, as `Textarea` does.
14
13
  */
15
14
  export function chatTextSize(kind: ChatTextKind, largerText: boolean): TextSize {
16
- if (kind === "body") return largerText ? "text-xl" : "text-base";
15
+ if (kind === "body") return largerText ? "text-xl" : "text-sm";
17
16
  if (kind === "small") return largerText ? "text-lg" : "text-sm";
18
17
  return largerText ? "text-base" : "text-xs";
19
18
  }
20
19
 
20
+ /**
21
+ * The web `text-2xs` token (11 / 16, tracking 0.01em) as a style, for
22
+ * `MessageMeta size="2xs"` and an app's own small labels. React Native has no
23
+ * `em`, so the tracking is in points.
24
+ */
25
+ export const META_TEXT_STYLE = { fontSize: 11, lineHeight: 16, letterSpacing: 0.11 } as const;
26
+
21
27
  /** The system monospace face, for code and reasoning text (web `font-mono`). */
22
28
  export const MONO_FONT = Platform.select({ ios: "Menlo", default: "monospace" });
23
29
 
@@ -1,6 +1,7 @@
1
1
  import type { ReactNode } from "react";
2
- import { KeyboardAvoidingView, View } from "react-native";
2
+ import { StyleSheet, View } from "react-native";
3
3
  import { cn } from "../cn";
4
+ import { KeyboardAvoidingView, KeyboardLiftContext } from "../primitives/keyboard-parts";
4
5
  import {
5
6
  Conversation,
6
7
  ConversationContent,
@@ -48,10 +49,10 @@ function lastIndexOfRole(messages: UIMessage[], role: UIMessage["role"]) {
48
49
  * The chat column as one piece: `Conversation`, `ConversationContent`, the
49
50
  * messages, a `thinking` row right after the last user message while
50
51
  * `working`, `ConversationScrollButton` and the `footer` last. The whole
51
- * column sits in a `KeyboardAvoidingView` (`padding`, as `PromptInput`), which
52
- * also lifts the footer on Android edge-to-edge, where the window does not
53
- * resize. A `PromptInput` footer has its own `KeyboardAvoidingView`; the outer
54
- * lift moves it clear of the keyboard, so it adds no second lift.
52
+ * column sits in a `KeyboardAvoidingView` (`padding`, as `PromptInput`; frame
53
+ * by frame with `KeyboardControllerProvider`), which also lifts the footer on
54
+ * Android edge-to-edge, where the window does not resize. A `PromptInput`
55
+ * footer and the scroll button add no lift of their own (`KeyboardLiftContext`).
55
56
  */
56
57
  export function ChatThread<M extends UIMessage = UIMessage>({
57
58
  messages,
@@ -69,28 +70,34 @@ export function ChatThread<M extends UIMessage = UIMessage>({
69
70
  const streaming = working ? lastIndexOfRole(messages, "assistant") : -1;
70
71
 
71
72
  return (
72
- <KeyboardAvoidingView
73
- testID={testID}
74
- behavior="padding"
75
- keyboardVerticalOffset={keyboardVerticalOffset}
76
- className={cn("flex-1", className)}
77
- >
78
- <Conversation>
79
- <ConversationContent contentContainerClassName={contentContainerClassName}>
80
- {messages.length === 0
81
- ? emptyState
82
- : messages.map((message, index) => (
83
- <View key={message.id}>
84
- {renderMessage(message, index === streaming)}
85
- {index === lastUser && thinking != null ? (
86
- <View className="mt-4">{thinking}</View>
87
- ) : null}
88
- </View>
89
- ))}
90
- </ConversationContent>
91
- <ConversationScrollButton testID="chat-thread-scroll-bottom" />
92
- </Conversation>
93
- {footer}
73
+ <KeyboardAvoidingView testID={testID} keyboardVerticalOffset={keyboardVerticalOffset} style={styles.fill}>
74
+ {/* The column lifts as a whole: the parts in it add no lift of their own. */}
75
+ <KeyboardLiftContext.Provider value>
76
+ <View className={cn("flex-1", className)}>
77
+ <Conversation>
78
+ <ConversationContent contentContainerClassName={contentContainerClassName}>
79
+ {messages.length === 0
80
+ ? emptyState
81
+ : messages.map((message, index) => (
82
+ <View key={message.id}>
83
+ {renderMessage(message, index === streaming)}
84
+ {index === lastUser && thinking != null ? (
85
+ <View className="mt-4">{thinking}</View>
86
+ ) : null}
87
+ </View>
88
+ ))}
89
+ </ConversationContent>
90
+ <ConversationScrollButton testID="chat-thread-scroll-bottom" />
91
+ </Conversation>
92
+ {footer}
93
+ </View>
94
+ </KeyboardLiftContext.Provider>
94
95
  </KeyboardAvoidingView>
95
96
  );
96
97
  }
98
+
99
+ const styles = StyleSheet.create({
100
+ fill: {
101
+ flex: 1,
102
+ },
103
+ });