@lotics/ui 4.6.1 → 4.7.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.
package/AGENTS.md CHANGED
@@ -75,7 +75,8 @@ Pick by capability, not by name. (→ the source file for the API.)
75
75
  - **Specialized work surfaces** — `ScanField` (scan/verify), `StepList` (guided run),
76
76
  `RemainderMeter` + `AllocationRow` (allocation), `Timeline`, `Calendar`, `Gantt`,
77
77
  `comments_thread`.
78
- - **AI surfaces** — `PromptField` (the NL command that triggers agent work NOT a chat),
78
+ - **AI surfaces** — `Composer` (the adaptive command/chat composer a compact pill when empty that
79
+ expands for long text + attachments; the surface that triggers agent work),
79
80
  `AgentRun` (the live streaming work feed) + `AgentProgress` (its compact, floating, expandable
80
81
  form — a composer's "working" state), `Suggestion` (review-for-approval of a proposal)
81
82
  + `Confidence`, `RecordReview` (an extracted record reviewed + edited before save — fields
@@ -222,15 +223,18 @@ accepts / edits / dismisses, the deterministic app applies. The agent owns judgm
222
223
  (recognition, estimation, intent→parameters); the app owns geometry, math, and the write.
223
224
  Compose the surfaces as a loop, and reach for the right one by job:
224
225
 
225
- - **Command** — `PromptField`, a single-row pill for a TEXT-ONLY trigger (Enter sends; the field
226
- locks + the send spins while it runs). The imperative "do this" surface. Its `onAttach` is a bare
227
- trigger with NO review step, so it CANNOT do attach→review→submit **never auto-run an agent on
228
- attach.** When a run needs a FILE + a prompt together (attach a photo, review/remove it, add a
229
- note, THEN send), reach for **`Composer`** instead: fill `actionsButton` with the attach button,
230
- the `files` slot with `FileThumbnail`s (each `onRemove`-able), and set `sendDisabled` so Send fires
231
- with an attachment and/or text (`onSend(text)` the host already holds the uploaded file id). The
232
- canvas (`tpl_dieline`) uses `Composer` for exactly this; `Composer` morphs into `AgentProgress`
233
- while running just like the pill.
226
+ - **Command / compose** — `Composer`, the adaptive command surface. Empty + minimal (no `footerRight`
227
+ model picker, no `pills`, no `files`), it's a COMPACT PILL: an optional `actionsButton` (attach) left,
228
+ the input, a send arrow right (Enter sends, Shift+Enter for a newline) the same geometry
229
+ `AgentProgress` morphs into. Type past one line, or add files, and it EXPANDS: the input lifts onto
230
+ its own row, the `files` slot (`FileThumbnail`s, each `onRemove`-able) and `pills` stack above, and
231
+ the buttons drop to a footer row (the expansion latches until the text is cleared). For a run that
232
+ needs a FILE + a prompt together (attach a photo, review/remove it, add a note, THEN send), fill
233
+ `actionsButton` + the `files` slot and set `sendDisabled` so Send fires with an attachment and/or
234
+ text (`onSend(text)` the host holds the uploaded file id). **Never auto-run an agent on attach** —
235
+ attaching HOLDS the file for review; the human presses Send. The canvas (`tpl_dieline`) uses it this
236
+ way; while running the host swaps `Composer` for `AgentProgress` (same pill geometry, so it reads as
237
+ a morph).
234
238
  - **Show the work** — `AgentRun`: a live feed of the agent's steps (reasoning, tool calls) on a
235
239
  spine, the running node a pulsing ink dot (a done step settles to a hollow ring). Transparent
236
240
  work, NEVER a bare spinner — the
@@ -316,9 +320,9 @@ controls — while **status/severity/diffs use functional colour** the way the r
316
320
  emerald.
317
321
 
318
322
  Card chrome (borders, microlabels) stays neutral — colour marks the *state*, never the container.
319
- The **composer keeps its icons**: `PromptField` is a single-row pill with circular attach + send
320
- `Button`s; `Composer` (the file+prompt variant) shows an attach `actionsButton` + a `FileThumbnail`
321
- attachment row (each `onRemove`-able) + send; `AgentProgress` is the `WaveAvatar` pill. "No icons"
323
+ The **composer keeps its icons**: `Composer` compact is a single-row pill with an optional circular
324
+ attach `actionsButton` + a circular send `Button`; expanded it adds a `FileThumbnail` attachment row
325
+ (each `onRemove`-able) above the input; `AgentProgress` is the `WaveAvatar` pill. "No icons"
322
326
  was only ever about the review surfaces' sparkles/severity glyphs, not functional affordances.
323
327
 
324
328
  ---
@@ -516,8 +520,8 @@ scan_field · file_dropzone · file_thumbnail · file_thumbnail_grid · file_pre
516
520
  file_gallery_modal · image_gallery · avatar · skeleton · activity_indicator · loading · divider ·
517
521
  spacer · stack · section_card · page_header · page_content · calendar (calendar/index.ts) · gantt ·
518
522
  comments_thread · agent_run (live streaming work feed) · agent_progress (its compact floating
519
- expandable form — a composer's working state) · prompt_field (the NL command surface + optional
520
- `onAttach`, not a chat) · suggestion (review-for-approval) · confidence (calibrated high/med/low) ·
523
+ expandable form — a composer's working state) · composer (Composer — the adaptive command/chat
524
+ composer: a compact pill that expands for long text + attachments) · suggestion (review-for-approval) · confidence (calibrated high/med/low) ·
521
525
  record_review (RecordReview — an extracted record reviewed + edited before save) · change_review (before→after — whole-set OR 1-by-1) ·
522
526
  clarify (the agent asks back, via ChoiceList) · choice_list (ChoiceList — selectable answer options,
523
527
  the agent's quick-reply surface) · sources (provenance chips, per-kind glyphs) · spec_list (SpecList — the exact
@@ -3,7 +3,7 @@ import { ScrollView, View } from "react-native";
3
3
  import { colors } from "@lotics/ui/colors";
4
4
  import { Text } from "@lotics/ui/text";
5
5
  import { DotsIndicator } from "@lotics/ui/dots_indicator";
6
- import { PromptField } from "@lotics/ui/prompt_field";
6
+ import { Composer } from "@lotics/ui/composer";
7
7
  import { ChangeReview, type ChangeReviewItem, type ChangeReviewItemStatus } from "@lotics/ui/change_review";
8
8
 
9
9
  // ─────────────────────────────────────────────────────────────────────────────
@@ -127,11 +127,11 @@ export function TplAssistant() {
127
127
 
128
128
  <View style={{ position: "absolute", left: 0, right: 0, bottom: 20, alignItems: "center", paddingHorizontal: 16 }}>
129
129
  <View style={{ width: "100%", maxWidth: 720 }}>
130
- <PromptField
130
+ <Composer
131
131
  value={prompt}
132
132
  onChangeText={setPrompt}
133
- onSubmit={send}
134
- busy={busy}
133
+ onSend={send}
134
+ disabled={busy}
135
135
  placeholder={proposed ? "Ask for another change…" : "Try: “Tidy up this contact”"}
136
136
  />
137
137
  </View>
@@ -5,7 +5,7 @@ import { colors } from "@lotics/ui/colors";
5
5
  import { Divider } from "@lotics/ui/divider";
6
6
  import { Callout, CalloutText, CalloutTitle } from "@lotics/ui/callout";
7
7
  import { PressableHighlight } from "@lotics/ui/pressable_highlight";
8
- import { PromptField } from "@lotics/ui/prompt_field";
8
+ import { Composer } from "@lotics/ui/composer";
9
9
  import { AgentRun, type AgentRunStep } from "@lotics/ui/agent_run";
10
10
  import { SpecList, type SpecRow } from "@lotics/ui/spec_list";
11
11
  import { Confidence, type ConfidenceLevel } from "@lotics/ui/confidence";
@@ -220,11 +220,11 @@ export function TplLookup() {
220
220
  </ScrollView>
221
221
 
222
222
  <View style={{ padding: 12, borderTopWidth: 1, borderTopColor: colors.zinc[200] }}>
223
- <PromptField
223
+ <Composer
224
224
  value={prompt}
225
225
  onChangeText={setPrompt}
226
- onSubmit={ask}
227
- busy={phase === "running"}
226
+ onSend={ask}
227
+ disabled={phase === "running"}
228
228
  placeholder="Describe goods, or refine — “what if it's pre-cooked?”"
229
229
  />
230
230
  </View>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "4.6.1",
3
+ "version": "4.7.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -60,7 +60,6 @@
60
60
  "./activity_indicator": "./src/activity_indicator.tsx",
61
61
  "./agent_run": "./src/agent_run.tsx",
62
62
  "./agent_progress": "./src/agent_progress.tsx",
63
- "./prompt_field": "./src/prompt_field.tsx",
64
63
  "./confidence": "./src/confidence.tsx",
65
64
  "./suggestion": "./src/suggestion.tsx",
66
65
  "./change_review": "./src/change_review.tsx",
@@ -19,8 +19,8 @@ export interface AgentProgressProps {
19
19
  * The compact, FLOATING agent-progress indicator — a pill (an animated
20
20
  * `WaveAvatar` + the current step's label) that EXPANDS on press to the full
21
21
  * `AgentRun` stream. It's the "working" state of a composer on a canvas app: the
22
- * composer (`PromptField`) MORPHS into this while the agent runs, and reveals
23
- * again when it's done. The whole pill is pressable; collapsed it shows only the
22
+ * composer (`Composer`'s compact pill) MORPHS into this while the agent runs, and
23
+ * reveals again when it's done. The whole pill is pressable; collapsed it shows only the
24
24
  * label, so the surface stays calm until you ask to see the steps.
25
25
  */
26
26
  export function AgentProgress(props: AgentProgressProps) {
@@ -56,9 +56,9 @@ export function AgentProgress(props: AgentProgressProps) {
56
56
  }
57
57
 
58
58
  const styles = StyleSheet.create({
59
- // Same pill geometry as PromptField (minHeight 52, radius 999, 1.5 border,
60
- // ph 6/left-padded for the avatar) so the composer→progress swap reads as a
61
- // morph, not a replacement.
59
+ // Same pill geometry as the Composer's compact pill (minHeight 52, radius 999,
60
+ // 1.5 border, ph 6/left-padded for the avatar) so the composer→progress swap
61
+ // reads as a morph, not a replacement.
62
62
  pill: {
63
63
  flexDirection: "row",
64
64
  alignItems: "center",
package/src/agent_run.tsx CHANGED
@@ -39,7 +39,7 @@ export interface AgentRunProps {
39
39
  * transparent-work surface, NOT a spinner. Shares the `Timeline` node anatomy: a
40
40
  * tinted disc behind a full-colour icon, fading in as each step arrives. A
41
41
  * running step spins (blue); a finished step settles to an emerald check; a
42
- * failure to a red alert. Pair with `PromptField` (the command that starts a
42
+ * failure to a red alert. Pair with `Composer` (the command that starts a
43
43
  * run) and `Suggestion` (the result to review). Unlike `StepList` (a known,
44
44
  * guided run of fixed steps) the steps here arrive unknown-ahead.
45
45
  */
package/src/composer.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import React, { type ReactNode, type Ref, useCallback, useState } from "react";
1
+ import React, { type ReactNode, type Ref, useCallback, useEffect, useState } from "react";
2
2
  import { View, TextInput as RNTextInput, ScrollView, StyleSheet, type TextInputProps } from "react-native";
3
3
  import { Button } from "./button";
4
4
  import { colors } from "./colors";
@@ -13,6 +13,15 @@ import { useAutoGrowHeight } from "./use_auto_grow_height";
13
13
  * them with skill/knowledge pills + an upload grid; a custom-code app fills
14
14
  * `files` with its own attachment region, or leaves it empty).
15
15
  *
16
+ * Adapts to its content. A *minimal* composer — no `footerRight` / `pills` /
17
+ * `files` / `children` — starts as a COMPACT PILL (a single row: an optional
18
+ * `actionsButton`, the input, the send button) so an empty command surface
19
+ * reads as a small pill, the same geometry `AgentProgress` morphs into. The
20
+ * moment the text wraps past one line (or files/pills/footer chrome appear) it
21
+ * EXPANDS: the input lifts onto its own row, slots stack above it, and the
22
+ * buttons drop to a footer row. The expansion latches until the text is cleared
23
+ * (the input is wider when expanded, so re-collapsing mid-edit would oscillate).
24
+ *
16
25
  * Pure by the package contract: no i18n, no domain types, no upload machinery.
17
26
  * Compose those above it.
18
27
  */
@@ -32,17 +41,19 @@ export interface ComposerProps {
32
41
  * built-in Enter-to-send and can `preventDefault()` to suppress it. */
33
42
  textInputProps?: Partial<TextInputProps> & { ref?: Ref<RNTextInput> };
34
43
 
35
- // Slots, rendered top-to-bottom above the input:
44
+ // Slots, rendered top-to-bottom above the input (force the expanded layout):
36
45
  /** A row of context pills (skills, mentions, …). */
37
46
  pills?: ReactNode;
38
47
  /** An attachment region (an upload grid, a thumbnail row, …). */
39
48
  files?: ReactNode;
40
49
  /** Arbitrary content between the slots and the input. */
41
50
  children?: ReactNode;
42
- /** Footer lefttypically an attach / actions button. */
51
+ /** An attach / actions button inline-left in the compact pill, footer-left when expanded. */
43
52
  actionsButton?: ReactNode;
44
- /** Footer right, before the send button — e.g. a model picker. */
53
+ /** Footer right, before the send button — e.g. a model picker. Forces the expanded layout. */
45
54
  footerRight?: ReactNode;
55
+ /** Max lines the expanded input grows to before it scrolls. */
56
+ maxLines?: number;
46
57
 
47
58
  /** Accessible/tooltip label for the send button. */
48
59
  sendLabel?: string;
@@ -65,6 +76,7 @@ export function Composer(props: ComposerProps) {
65
76
  children,
66
77
  actionsButton,
67
78
  footerRight,
79
+ maxLines = 10,
68
80
  sendLabel = "Send",
69
81
  stopLabel = "Stop",
70
82
  } = props;
@@ -75,9 +87,29 @@ export function Composer(props: ComposerProps) {
75
87
  const setText = isControlled ? (props.onChangeText ?? setInternalText) : setInternalText;
76
88
 
77
89
  const [focused, setFocused] = useState(false);
78
- const { containerHeight, scrollEnabled, inputRef, measure, onContentSizeChange } = useAutoGrowHeight({ maxLines: 12 });
90
+ const { containerHeight, lineHeight, scrollEnabled, inputRef, measure, onContentSizeChange } = useAutoGrowHeight({
91
+ maxLines,
92
+ });
79
93
  const emptyText = text.trim().length === 0;
80
94
 
95
+ // The compact pill only exists for a minimal composer (no footer chrome / no
96
+ // stacked slots). Once it wraps past one line we EXPAND and stay there until
97
+ // the text is cleared — re-collapsing mid-edit would oscillate, because the
98
+ // input is narrower in the pill (flanked by buttons) than when it's full-width.
99
+ // The latch keys on RAW length, not trimmed: blank lines (Shift+Enter) are real
100
+ // content that should keep the composer expanded; only a truly empty field
101
+ // collapses back to the pill. (Send still gates on `emptyText` — no whitespace-
102
+ // only sends.)
103
+ const minimal = footerRight == null && pills == null && files == null && children == null;
104
+ const [grew, setGrew] = useState(false);
105
+ const isEmpty = text.length === 0;
106
+ const wrapped = containerHeight > lineHeight + 4;
107
+ useEffect(() => {
108
+ if (isEmpty) setGrew(false);
109
+ else if (wrapped) setGrew(true);
110
+ }, [isEmpty, wrapped]);
111
+ const expanded = !minimal || grew;
112
+
81
113
  const { ref: externalRef, ...spreadInputProps } = textInputProps ?? {};
82
114
  const mergedInputRef = useCallback(
83
115
  (node: RNTextInput | null) => {
@@ -122,86 +154,128 @@ export function Composer(props: ComposerProps) {
122
154
  [handleSend, spreadInputProps],
123
155
  );
124
156
 
125
- const hasContentAboveInput = pills != null || files != null || children != null;
157
+ const sendButton =
158
+ disabled && onStop ? (
159
+ <Button onPress={onStop} color="primary" icon="square" tooltip={stopLabel} accessibilityLabel={stopLabel} />
160
+ ) : (
161
+ <Button
162
+ onPress={handleSend}
163
+ color="primary"
164
+ disabled={disabled || effectiveSendDisabled}
165
+ icon="arrow-up"
166
+ tooltip={sendLabel}
167
+ accessibilityLabel={sendLabel}
168
+ />
169
+ );
170
+
171
+ // The input lives at a stable keyed position so it is never remounted when the
172
+ // layout flips between compact and expanded — the caret and focus survive the
173
+ // keystroke that triggers the expansion. When compact, the input is a FIXED
174
+ // single-row height (a bigger click target than the line itself, but the pill
175
+ // stays 52px — a flex/auto height would let the multiline textarea balloon to
176
+ // its 2-row intrinsic size). Its line is vertically centred by symmetric
177
+ // padding; `measureScrollHeight` subtracts that padding, so wrap-detection is
178
+ // unaffected. Growth past one line is the EXPANDED layout's job (up to maxLines).
179
+ const compactPadV = Math.max(2, Math.round((COMPACT_INPUT_HEIGHT - lineHeight) / 2));
180
+ const field = (
181
+ <View
182
+ key="field"
183
+ onLayout={measure}
184
+ style={expanded ? { height: containerHeight } : [styles.field, { height: COMPACT_INPUT_HEIGHT }]}
185
+ >
186
+ <RNTextInput
187
+ ref={mergedInputRef}
188
+ autoFocus={autoFocus}
189
+ onChangeText={handleChangeText}
190
+ onSubmitEditing={handleSend}
191
+ placeholder={placeholder}
192
+ placeholderTextColor={colors.zinc[500]}
193
+ value={text}
194
+ onFocus={() => setFocused(true)}
195
+ onBlur={() => setFocused(false)}
196
+ multiline
197
+ scrollEnabled={expanded && scrollEnabled}
198
+ onContentSizeChange={onContentSizeChange}
199
+ {...spreadInputProps}
200
+ onKeyPress={handleKeyPress}
201
+ style={[
202
+ styles.textInput,
203
+ getInputTextStyle(),
204
+ { outlineStyle: "none" },
205
+ (!expanded || !scrollEnabled) && { overflow: "hidden" as const },
206
+ expanded ? null : { paddingHorizontal: 10, paddingVertical: compactPadV },
207
+ spreadInputProps?.style,
208
+ ]}
209
+ />
210
+ </View>
211
+ );
126
212
 
127
213
  return (
128
- <View testID={testID} style={[styles.container, focused && styles.focused]}>
129
- <View style={[styles.inputContainer, hasContentAboveInput && styles.inputContainerWithContent]}>
130
- {pills != null && <View style={styles.pillRow}>{pills}</View>}
131
- {files != null && (
132
- <ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 8 }}>
133
- {files}
134
- </ScrollView>
135
- )}
136
- {children}
137
- <View style={{ justifyContent: "center" }}>
138
- <View style={{ height: containerHeight }}>
139
- <RNTextInput
140
- ref={mergedInputRef}
141
- autoFocus={autoFocus}
142
- onChangeText={handleChangeText}
143
- onSubmitEditing={handleSend}
144
- placeholder={placeholder}
145
- placeholderTextColor={colors.zinc[500]}
146
- value={text}
147
- onFocus={() => setFocused(true)}
148
- onBlur={() => setFocused(false)}
149
- multiline
150
- scrollEnabled={scrollEnabled}
151
- onContentSizeChange={onContentSizeChange}
152
- {...spreadInputProps}
153
- onKeyPress={handleKeyPress}
154
- style={[
155
- styles.textInput,
156
- getInputTextStyle(),
157
- { outlineStyle: "none" },
158
- !scrollEnabled && { overflow: "hidden" as const },
159
- spreadInputProps?.style,
160
- ]}
161
- />
162
- </View>
214
+ <View
215
+ testID={testID}
216
+ style={[
217
+ expanded ? styles.boxContainer : styles.pillContainer,
218
+ focused && (expanded ? styles.boxFocused : styles.pillFocused),
219
+ ]}
220
+ >
221
+ {expanded && pills != null ? (
222
+ <View key="pills" style={styles.pillRow}>
223
+ {pills}
163
224
  </View>
225
+ ) : null}
226
+ {expanded && files != null ? (
227
+ <ScrollView key="files" horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={{ gap: 8 }}>
228
+ {files}
229
+ </ScrollView>
230
+ ) : null}
231
+ {expanded && children != null ? <View key="children">{children}</View> : null}
232
+
233
+ <View key="input" style={expanded ? styles.inputAreaExpanded : styles.pillRowLayout}>
234
+ {!expanded && actionsButton != null ? <View key="lead">{actionsButton}</View> : null}
235
+ {field}
236
+ {!expanded ? <View key="trail">{sendButton}</View> : null}
164
237
  </View>
165
238
 
166
- <View style={styles.footer}>
167
- <View style={styles.footerLeft}>{actionsButton}</View>
168
- <View style={styles.footerRight}>
169
- {footerRight}
170
- {disabled && onStop ? (
171
- <Button onPress={onStop} color="primary" icon="square" tooltip={stopLabel} />
172
- ) : (
173
- <Button
174
- onPress={handleSend}
175
- color="primary"
176
- disabled={disabled || effectiveSendDisabled}
177
- icon="arrow-up"
178
- tooltip={sendLabel}
179
- />
180
- )}
239
+ {expanded ? (
240
+ <View key="footer" style={styles.footer}>
241
+ <View style={styles.footerLeft}>{actionsButton}</View>
242
+ <View style={styles.footerRight}>
243
+ {footerRight}
244
+ {sendButton}
245
+ </View>
181
246
  </View>
182
- </View>
247
+ ) : null}
183
248
  </View>
184
249
  );
185
250
  }
186
251
 
252
+ const PILL_HEIGHT = 52;
253
+ // Fixed single-row input height inside the compact pill — a tall click target
254
+ // that keeps the pill at PILL_HEIGHT (a flex/auto height balloons the textarea).
255
+ const COMPACT_INPUT_HEIGHT = 44;
256
+
187
257
  const styles = StyleSheet.create({
188
- container: {
258
+ // Expanded — the full chat composer box.
259
+ boxContainer: {
189
260
  padding: 8,
190
261
  borderRadius: 16,
191
262
  borderWidth: 2,
192
263
  borderColor: colors.border,
193
264
  backgroundColor: colors.background,
265
+ gap: 8,
194
266
  },
195
- focused: {
267
+ boxFocused: {
196
268
  borderColor: colors.black,
197
269
  },
270
+ inputAreaExpanded: {
271
+ padding: 4,
272
+ justifyContent: "center",
273
+ },
198
274
  footer: {
199
- flex: 1,
200
275
  flexDirection: "row",
201
276
  gap: 8,
202
277
  justifyContent: "space-between",
203
278
  zIndex: 1,
204
- paddingTop: 8,
205
279
  },
206
280
  footerLeft: {
207
281
  flexDirection: "row",
@@ -212,17 +286,33 @@ const styles = StyleSheet.create({
212
286
  alignItems: "center",
213
287
  gap: 8,
214
288
  },
215
- inputContainer: {
216
- padding: 4,
217
- },
218
- inputContainerWithContent: {
219
- gap: 8,
220
- },
221
289
  pillRow: {
222
290
  flexDirection: "row",
223
291
  flexWrap: "wrap",
224
292
  gap: 4,
225
293
  },
294
+ // Compact — the command pill (matches AgentProgress's pill geometry so the
295
+ // run-morph reads as a morph, not a replacement).
296
+ pillContainer: {
297
+ minHeight: PILL_HEIGHT,
298
+ justifyContent: "center",
299
+ paddingHorizontal: 6,
300
+ borderRadius: 999,
301
+ borderWidth: 1.5,
302
+ borderColor: colors.border,
303
+ backgroundColor: colors.white,
304
+ },
305
+ pillFocused: {
306
+ borderColor: colors.zinc[900],
307
+ },
308
+ pillRowLayout: {
309
+ flexDirection: "row",
310
+ alignItems: "center",
311
+ gap: 6,
312
+ },
313
+ field: {
314
+ flex: 1,
315
+ },
226
316
  textInput: {
227
317
  flex: 1,
228
318
  fontFamily: fontFamilyRegular,
@@ -10,6 +10,9 @@ interface UseAutoGrowHeightOptions {
10
10
 
11
11
  interface UseAutoGrowHeightResult {
12
12
  containerHeight: number;
13
+ /** Height of a single line at the current screen size — compare against
14
+ * `containerHeight` to tell whether the content has wrapped past one line. */
15
+ lineHeight: number;
13
16
  scrollEnabled: boolean;
14
17
  inputRef: (node: TextInput | null) => void;
15
18
  measure: () => void;
@@ -81,6 +84,7 @@ export function useAutoGrowHeight(options: UseAutoGrowHeightOptions = {}): UseAu
81
84
 
82
85
  return {
83
86
  containerHeight,
87
+ lineHeight,
84
88
  scrollEnabled,
85
89
  inputRef,
86
90
  measure,
@@ -1,108 +0,0 @@
1
- import { useCallback, useState } from "react";
2
- import {
3
- NativeSyntheticEvent,
4
- StyleSheet,
5
- TextInput as RNTextInput,
6
- TextInputKeyPressEventData,
7
- View,
8
- } from "react-native";
9
- import { colors } from "./colors";
10
- import { Button } from "./button";
11
- import { getInputTextStyle, fontFamilyRegular } from "./text_utils";
12
-
13
- export interface PromptFieldProps {
14
- value: string;
15
- onChangeText: (value: string) => void;
16
- /** Fired on Enter or the send button. Receives the trimmed text; never fires
17
- * empty or while busy. */
18
- onSubmit: (value: string) => void;
19
- placeholder?: string;
20
- /** The agent is working — the field locks. Typically the host swaps the field
21
- * for `AgentProgress` while busy (the composer MORPHS into the progress
22
- * pill); the two share the same pill geometry so the swap reads as a morph. */
23
- busy?: boolean;
24
- disabled?: boolean;
25
- autoFocus?: boolean;
26
- accessibilityLabel?: string;
27
- /** When set, a circular attach button sits at the left of the row — the
28
- * composer can attach files (e.g. the photo that starts a run). */
29
- onAttach?: () => void;
30
- }
31
-
32
- const ROW = 40;
33
-
34
- /**
35
- * The natural-language command surface for an AI app — a compact single-row
36
- * composer pill that triggers agent work, NOT a chat composer. A circular attach
37
- * `Button` on the left, the input filling the row at the same height as the
38
- * buttons, a circular send-arrow `Button` on the right; Enter sends. While the
39
- * agent runs the host swaps this for `AgentProgress` — same pill geometry, so it
40
- * morphs into the progress indicator.
41
- */
42
- export function PromptField(props: PromptFieldProps) {
43
- const { value, onChangeText, onSubmit, placeholder, busy, disabled, autoFocus, accessibilityLabel, onAttach } = props;
44
- const [focused, setFocused] = useState(false);
45
-
46
- const canSubmit = value.trim().length > 0 && !busy && !disabled;
47
-
48
- const submit = useCallback(() => {
49
- if (value.trim().length === 0 || busy || disabled) return;
50
- onSubmit(value.trim());
51
- }, [value, busy, disabled, onSubmit]);
52
-
53
- const onKeyPress = useCallback(
54
- (e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
55
- const ne = e.nativeEvent as unknown as { key: string; shiftKey?: boolean };
56
- if (ne.key === "Enter" && !ne.shiftKey) {
57
- (e as unknown as { preventDefault?: () => void }).preventDefault?.();
58
- submit();
59
- }
60
- },
61
- [submit],
62
- );
63
-
64
- return (
65
- <View style={[styles.box, focused ? styles.boxFocused : null, disabled ? styles.boxDisabled : null]}>
66
- {onAttach ? (
67
- <Button icon="paperclip" color="secondary" accessibilityLabel="Attach files" onPress={onAttach} disabled={busy || disabled} />
68
- ) : null}
69
- <RNTextInput
70
- accessibilityLabel={accessibilityLabel ?? placeholder}
71
- style={[styles.input, getInputTextStyle(), { outline: "none", outlineStyle: "none", outlineWidth: 0 } as object]}
72
- value={value}
73
- onChangeText={onChangeText}
74
- onKeyPress={onKeyPress}
75
- onSubmitEditing={submit}
76
- onFocus={() => setFocused(true)}
77
- onBlur={() => setFocused(false)}
78
- placeholder={placeholder}
79
- placeholderTextColor={colors.zinc[400]}
80
- editable={!busy && !disabled}
81
- autoFocus={autoFocus}
82
- />
83
- <Button icon="arrow-up" color="primary" accessibilityLabel="Send" onPress={submit} disabled={!canSubmit} />
84
- </View>
85
- );
86
- }
87
-
88
- const styles = StyleSheet.create({
89
- box: {
90
- flexDirection: "row",
91
- alignItems: "center",
92
- gap: 6,
93
- minHeight: 52,
94
- borderWidth: 1.5,
95
- borderColor: colors.border,
96
- borderRadius: 999,
97
- backgroundColor: colors.white,
98
- paddingHorizontal: 6,
99
- },
100
- boxFocused: { borderColor: colors.zinc[900] },
101
- boxDisabled: { backgroundColor: colors.zinc[50] },
102
- input: {
103
- flex: 1,
104
- height: ROW,
105
- paddingHorizontal: 10,
106
- fontFamily: fontFamilyRegular,
107
- } as object,
108
- });