@lotics/ui 4.6.0 → 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,8 +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`, NOT a chat composer. A prompt TRIGGERS agent work (Enter sends);
226
- while it runs the field locks and the send spins. The imperative "do this" surface.
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).
227
238
  - **Show the work** — `AgentRun`: a live feed of the agent's steps (reasoning, tool calls) on a
228
239
  spine, the running node a pulsing ink dot (a done step settles to a hollow ring). Transparent
229
240
  work, NEVER a bare spinner — the
@@ -252,8 +263,9 @@ Compose the surfaces as a loop, and reach for the right one by job:
252
263
  Ten shapes prove the range. The first four PRODUCE — pick by whether the work is a living artifact,
253
264
  a conversation, a single pass, or generated prose:
254
265
  - **Canvas** (`tpl_dieline`) — the page IS the design on a pannable/zoomable surface: it stays
255
- CENTRED at every zoom (a floating zoom pill bottom-left), the FLOATING composer at the bottom
256
- morphs into `AgentProgress` while running, and a pinned PARAMS PANEL (the `RecordReview` field-card
266
+ CENTRED at every zoom (a floating zoom pill bottom-left), the FLOATING composer at the bottom (a
267
+ `Composer`: attach a photo review/remove it → add a note send, with `sendDisabled` allowing a
268
+ file-and/or-text submit — NOT auto-run on attach) morphs into `AgentProgress` while running, and a pinned PARAMS PANEL (the `RecordReview` field-card
257
269
  in live-edit mode, carrying the single Download; minimizes to a pill) floats centre-right. Panel, zoom pill and composer
258
270
  float ON TOP — they never shift the design. (Floating layers use a `pointerEvents:"none"` wrapper
259
271
  with the interactive child set `"auto"`; RN-Web ignores `"box-none"` in style, so a full-width
@@ -308,9 +320,10 @@ controls — while **status/severity/diffs use functional colour** the way the r
308
320
  emerald.
309
321
 
310
322
  Card chrome (borders, microlabels) stays neutral — colour marks the *state*, never the container.
311
- The **composer keeps its icons**: `PromptField` is a single-row pill with circular attach + send
312
- `Button`s; `AgentProgress` is the `WaveAvatar` pill. "No icons" was only ever about the review
313
- surfaces' sparkles/severity glyphs, not functional affordances.
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"
326
+ was only ever about the review surfaces' sparkles/severity glyphs, not functional affordances.
314
327
 
315
328
  ---
316
329
 
@@ -507,8 +520,8 @@ scan_field · file_dropzone · file_thumbnail · file_thumbnail_grid · file_pre
507
520
  file_gallery_modal · image_gallery · avatar · skeleton · activity_indicator · loading · divider ·
508
521
  spacer · stack · section_card · page_header · page_content · calendar (calendar/index.ts) · gantt ·
509
522
  comments_thread · agent_run (live streaming work feed) · agent_progress (its compact floating
510
- expandable form — a composer's working state) · prompt_field (the NL command surface + optional
511
- `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) ·
512
525
  record_review (RecordReview — an extracted record reviewed + edited before save) · change_review (before→after — whole-set OR 1-by-1) ·
513
526
  clarify (the agent asks back, via ChoiceList) · choice_list (ChoiceList — selectable answer options,
514
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>
@@ -8,9 +8,19 @@ import { PressableHighlight } from "@lotics/ui/pressable_highlight";
8
8
  import { DotsIndicator } from "@lotics/ui/dots_indicator";
9
9
  import { InlineTextInput } from "@lotics/ui/inline_text_input";
10
10
  import { AgentProgress } from "@lotics/ui/agent_progress";
11
- import { PromptField } from "@lotics/ui/prompt_field";
11
+ import { Composer } from "@lotics/ui/composer";
12
+ import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
12
13
  import { type AgentRunStep } from "@lotics/ui/agent_run";
13
14
 
15
+ // A real app fills the attachment from `useFileUpload` (the uploaded file's URL);
16
+ // this mock uses an inline placeholder so the example needs no network image.
17
+ const MOCK_PHOTO: DisplayFile = {
18
+ id: "att-carton",
19
+ filename: "carton.jpg",
20
+ mimeType: "image/jpeg",
21
+ url: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==",
22
+ };
23
+
14
24
  // ─────────────────────────────────────────────────────────────────────────────
15
25
  // Template · AI design canvas (the carton "dieline" app) — the FLAGSHIP shape:
16
26
  // the page IS the design. The dieline fills the centre, a FLOATING composer sits
@@ -56,6 +66,7 @@ export function TplDieline() {
56
66
  const [pan, setPan] = useState({ x: 0, y: 0 });
57
67
  const [dragging, setDragging] = useState(false);
58
68
  const [prompt, setPrompt] = useState("");
69
+ const [attachment, setAttachment] = useState<DisplayFile | null>(null);
59
70
 
60
71
  // Drag-to-pan — like a design tool: grab anywhere on the canvas and move it.
61
72
  // The committed pan is read at grant via a ref (the responder is built once).
@@ -116,6 +127,7 @@ export function TplDieline() {
116
127
  const submitPrompt = (text: string) => {
117
128
  setPrompt("");
118
129
  if (!designReady) {
130
+ setAttachment(null); // the attached photo is consumed into the run
119
131
  startRecognize();
120
132
  return;
121
133
  }
@@ -136,6 +148,7 @@ export function TplDieline() {
136
148
 
137
149
  const newSession = () => {
138
150
  setRun(null);
151
+ setAttachment(null);
139
152
  setRevealed(0);
140
153
  setDesignReady(false);
141
154
  setDims({ L: 320, W: 230, H: 150 });
@@ -197,12 +210,16 @@ export function TplDieline() {
197
210
  {phase === "working" ? (
198
211
  <AgentProgress steps={liveSteps} state="streaming" />
199
212
  ) : (
200
- <PromptField
213
+ <Composer
201
214
  value={prompt}
202
215
  onChangeText={setPrompt}
203
- onSubmit={submitPrompt}
204
- onAttach={startRecognize}
205
- placeholder={designReady ? "Describe a change — “make it 5 mm taller”" : "Attach a photo of the carton to start…"}
216
+ onSend={submitPrompt}
217
+ sendDisabled={prompt.trim().length === 0 && !attachment}
218
+ placeholder={designReady ? "Describe a change — “make it 5 mm taller”" : "Attach a photo, add a note, then send…"}
219
+ actionsButton={
220
+ <Button icon="paperclip" color="secondary" accessibilityLabel="Attach a photo" onPress={() => setAttachment(MOCK_PHOTO)} />
221
+ }
222
+ files={attachment ? <FileThumbnail file={attachment} size={56} onRemove={() => setAttachment(null)} /> : null}
206
223
  />
207
224
  )}
208
225
  </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.0",
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
- });