@lotics/ui 4.6.1 → 4.8.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
@@ -56,7 +56,9 @@ Pick by capability, not by name. (→ the source file for the API.)
56
56
  with `Pagination`). Never an HTML `<table>` or a `.map` of rows.
57
57
  - **Numbers & charts** — `KPIStrip` / `KPICard` / `Metric` (headline figures), `TrendChip`
58
58
  (delta), `Sparkline`, `BarChart` / `LineChart` / `PieChart` (the canonical SVG set — no
59
- recharts), `RingGauge`, `ProgressBar` / `StackedProgressBar` / `StepProgress`, `Breakdown`,
59
+ recharts), `RingGauge`, `ProgressBar` / `StackedProgressBar` / `StepProgress`, `Breakdown`
60
+ (a stacked bar + ranked share rows, pressable to drill; `maxRows` folds the long tail behind a
61
+ "Show N more" toggle — `labels` to localize — so several facet cards align to one height in a row),
60
62
  `StatusGrid` + `StatusLegend`, `Heatmap`.
61
63
  - **Surfaces & layout** — `Card` (+ `CardHeader`/`CardHeaderTitle`/`CardHeaderMeta`/`CardBody`/
62
64
  `CardFooter`), `SectionCard`, `SectionHeading` (+ `SectionHeadingTitle`), `PageHeader` /
@@ -75,7 +77,8 @@ Pick by capability, not by name. (→ the source file for the API.)
75
77
  - **Specialized work surfaces** — `ScanField` (scan/verify), `StepList` (guided run),
76
78
  `RemainderMeter` + `AllocationRow` (allocation), `Timeline`, `Calendar`, `Gantt`,
77
79
  `comments_thread`.
78
- - **AI surfaces** — `PromptField` (the NL command that triggers agent work NOT a chat),
80
+ - **AI surfaces** — `Composer` (the adaptive command/chat composer a compact pill when empty that
81
+ expands for long text + attachments; the surface that triggers agent work),
79
82
  `AgentRun` (the live streaming work feed) + `AgentProgress` (its compact, floating, expandable
80
83
  form — a composer's "working" state), `Suggestion` (review-for-approval of a proposal)
81
84
  + `Confidence`, `RecordReview` (an extracted record reviewed + edited before save — fields
@@ -222,15 +225,18 @@ accepts / edits / dismisses, the deterministic app applies. The agent owns judgm
222
225
  (recognition, estimation, intent→parameters); the app owns geometry, math, and the write.
223
226
  Compose the surfaces as a loop, and reach for the right one by job:
224
227
 
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.
228
+ - **Command / compose** — `Composer`, the adaptive command surface. Empty + minimal (no `footerRight`
229
+ model picker, no `pills`, no `files`), it's a COMPACT PILL: an optional `actionsButton` (attach) left,
230
+ the input, a send arrow right (Enter sends, Shift+Enter for a newline) the same geometry
231
+ `AgentProgress` morphs into. Type past one line, or add files, and it EXPANDS: the input lifts onto
232
+ its own row, the `files` slot (`FileThumbnail`s, each `onRemove`-able) and `pills` stack above, and
233
+ the buttons drop to a footer row (the expansion latches until the text is cleared). For a run that
234
+ needs a FILE + a prompt together (attach a photo, review/remove it, add a note, THEN send), fill
235
+ `actionsButton` + the `files` slot and set `sendDisabled` so Send fires with an attachment and/or
236
+ text (`onSend(text)` the host holds the uploaded file id). **Never auto-run an agent on attach** —
237
+ attaching HOLDS the file for review; the human presses Send. The canvas (`tpl_dieline`) uses it this
238
+ way; while running the host swaps `Composer` for `AgentProgress` (same pill geometry, so it reads as
239
+ a morph).
234
240
  - **Show the work** — `AgentRun`: a live feed of the agent's steps (reasoning, tool calls) on a
235
241
  spine, the running node a pulsing ink dot (a done step settles to a hollow ring). Transparent
236
242
  work, NEVER a bare spinner — the
@@ -316,9 +322,9 @@ controls — while **status/severity/diffs use functional colour** the way the r
316
322
  emerald.
317
323
 
318
324
  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"
325
+ The **composer keeps its icons**: `Composer` compact is a single-row pill with an optional circular
326
+ attach `actionsButton` + a circular send `Button`; expanded it adds a `FileThumbnail` attachment row
327
+ (each `onRemove`-able) above the input; `AgentProgress` is the `WaveAvatar` pill. "No icons"
322
328
  was only ever about the review surfaces' sparkles/severity glyphs, not functional affordances.
323
329
 
324
330
  ---
@@ -516,8 +522,8 @@ scan_field · file_dropzone · file_thumbnail · file_thumbnail_grid · file_pre
516
522
  file_gallery_modal · image_gallery · avatar · skeleton · activity_indicator · loading · divider ·
517
523
  spacer · stack · section_card · page_header · page_content · calendar (calendar/index.ts) · gantt ·
518
524
  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) ·
525
+ expandable form — a composer's working state) · composer (Composer — the adaptive command/chat
526
+ composer: a compact pill that expands for long text + attachments) · suggestion (review-for-approval) · confidence (calibrated high/med/low) ·
521
527
  record_review (RecordReview — an extracted record reviewed + edited before save) · change_review (before→after — whole-set OR 1-by-1) ·
522
528
  clarify (the agent asks back, via ChoiceList) · choice_list (ChoiceList — selectable answer options,
523
529
  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>
@@ -56,6 +56,11 @@ const DEPOTS = [
56
56
  { key: "northgate", label: "Northgate" },
57
57
  { key: "riverside", label: "Riverside" },
58
58
  { key: "southbay", label: "Southbay" },
59
+ { key: "westpoint", label: "Westpoint" },
60
+ { key: "harborview", label: "Harborview" },
61
+ { key: "lakeside", label: "Lakeside" },
62
+ { key: "midvale", label: "Midvale" },
63
+ { key: "fairlawn", label: "Fairlawn" },
59
64
  ] as const;
60
65
  const DEPOT_RAMP = ramp("violet", DEPOTS.length);
61
66
 
@@ -101,7 +106,7 @@ const TYPE_BASE_VALUE: Record<TypeKey, number> = {
101
106
  const UNITS: Unit[] = Array.from({ length: 10_482 }, (_, i) => {
102
107
  const type = pick<TypeKey>(i, 1, [["dry40", 34], ["dry20", 26], ["hc40", 18], ["reefer", 12], ["opentop", 7], ["tank", 3]]);
103
108
  const status = pick<StatusKey>(i, 2, [["leased", 52], ["yard", 18], ["transit", 14], ["repair", 9], ["idle", 7]]);
104
- const depot = pick<DepotKey>(i, 3, [["eastport", 38], ["northgate", 27], ["riverside", 21], ["southbay", 14]]);
109
+ const depot = pick<DepotKey>(i, 3, [["eastport", 26], ["northgate", 19], ["riverside", 15], ["southbay", 11], ["westpoint", 9], ["harborview", 7], ["lakeside", 5], ["midvale", 4], ["fairlawn", 4]]);
105
110
  const ageYears = 1 + (hash(i, 4) % 12);
106
111
  const day = 1 + (hash(i, 5) % 28);
107
112
  const month = status === "idle" ? 1 + (hash(i, 6) % 4) : 4 + (hash(i, 6) % 3);
@@ -241,10 +246,13 @@ export function TplStock() {
241
246
  </CardHeaderTitle>
242
247
  </CardHeader>
243
248
  <View style={{ paddingHorizontal: 20, paddingVertical: 16 }}>
249
+ {/* maxRows caps the long tail behind a "Show N more" toggle, so a
250
+ many-depot facet stays the same height as its neighbours. */}
244
251
  <Breakdown
245
252
  items={DEPOTS.map((d, i) => ({ key: d.key, label: d.label, value: byDepot.get(d.key) ?? 0, color: DEPOT_RAMP[i] }))}
246
253
  selectedKey={depot}
247
254
  onSelect={setFacet(setDepot)}
255
+ maxRows={5}
248
256
  />
249
257
  </View>
250
258
  </Card>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "4.6.1",
3
+ "version": "4.8.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/breakdown.tsx CHANGED
@@ -1,5 +1,7 @@
1
+ import { useState } from "react";
1
2
  import { StyleSheet, View } from "react-native";
2
3
  import { colors, withAlpha } from "./colors";
4
+ import { Icon } from "./icon";
3
5
  import { PressableHighlight } from "./pressable_highlight";
4
6
  import { StackedProgressBar } from "./stacked_progress_bar";
5
7
  import { Text } from "./text";
@@ -12,6 +14,15 @@ export interface BreakdownItem {
12
14
  color: string;
13
15
  }
14
16
 
17
+ /** Expand/collapse labels for a capped breakdown (`maxRows`). Localized
18
+ * downstream — the kit ships English defaults. */
19
+ export interface BreakdownLabels {
20
+ /** The expand affordance, given how many rows are currently hidden. */
21
+ more: (hidden: number) => string;
22
+ /** The collapse affordance. */
23
+ less: string;
24
+ }
25
+
15
26
  export interface BreakdownProps {
16
27
  items: BreakdownItem[];
17
28
  /** The drilled-in segment. While set, the other segments dim. */
@@ -20,9 +31,17 @@ export interface BreakdownProps {
20
31
  * render an informational breakdown. */
21
32
  onSelect?: (key: string | null) => void;
22
33
  formatValue?: (n: number) => string;
34
+ /** Cap the visible rows; the rest fold behind a "show more" toggle. Keeps a
35
+ * long-tail breakdown a fixed height (so several align in a row) while staying
36
+ * fully drillable on expand. The stacked bar always reflects ALL items. Omit
37
+ * to show every row. */
38
+ maxRows?: number;
39
+ /** Expand/collapse labels (defaults to English). */
40
+ labels?: BreakdownLabels;
23
41
  }
24
42
 
25
43
  const defaultFormat = (n: number): string => n.toLocaleString("en-US");
44
+ const DEFAULT_LABELS: BreakdownLabels = { more: (hidden) => `Show ${hidden} more`, less: "Show less" };
26
45
 
27
46
  /**
28
47
  * Composition browser for LARGE populations — the answer to "what is my
@@ -34,8 +53,15 @@ const defaultFormat = (n: number): string => n.toLocaleString("en-US");
34
53
  * full faceted overview; selections combine in the host's filter state.
35
54
  */
36
55
  export function Breakdown(props: BreakdownProps) {
37
- const { items, selectedKey = null, onSelect, formatValue = defaultFormat } = props;
56
+ const { items, selectedKey = null, onSelect, formatValue = defaultFormat, maxRows, labels = DEFAULT_LABELS } = props;
38
57
  const total = items.reduce((sum, item) => sum + item.value, 0);
58
+ const [expanded, setExpanded] = useState(false);
59
+ // Cap the rows when `maxRows` is set and the tail is longer; the bar still
60
+ // reflects every item, only the ranked list folds.
61
+ const cap = maxRows ?? items.length;
62
+ const collapsible = items.length > cap;
63
+ const hidden = items.length - cap;
64
+ const visible = collapsible && !expanded ? items.slice(0, cap) : items;
39
65
 
40
66
  return (
41
67
  <View style={styles.container}>
@@ -49,7 +75,7 @@ export function Breakdown(props: BreakdownProps) {
49
75
  }))}
50
76
  />
51
77
  <View>
52
- {items.map((item) => {
78
+ {visible.map((item) => {
53
79
  const share = total > 0 ? Math.round((item.value / total) * 100) : 0;
54
80
  const selected = selectedKey === item.key;
55
81
  const dimmed = selectedKey !== null && !selected;
@@ -84,6 +110,20 @@ export function Breakdown(props: BreakdownProps) {
84
110
  </View>
85
111
  );
86
112
  })}
113
+ {collapsible ? (
114
+ <PressableHighlight
115
+ accessibilityRole="button"
116
+ accessibilityState={{ expanded }}
117
+ accessibilityLabel={expanded ? labels.less : labels.more(hidden)}
118
+ onPress={() => setExpanded((e) => !e)}
119
+ style={[styles.row, styles.pressable, styles.toggle]}
120
+ >
121
+ <Text size="sm" weight="medium" color="muted">
122
+ {expanded ? labels.less : labels.more(hidden)}
123
+ </Text>
124
+ <Icon name={expanded ? "chevron-up" : "chevron-down"} size={16} color={colors.zinc[400]} />
125
+ </PressableHighlight>
126
+ ) : null}
87
127
  </View>
88
128
  </View>
89
129
  );
@@ -96,7 +136,7 @@ const styles = StyleSheet.create({
96
136
  row: {
97
137
  flexDirection: "row",
98
138
  alignItems: "center",
99
- gap: 10,
139
+ gap: 8,
100
140
  minHeight: 36,
101
141
  },
102
142
  pressable: {
@@ -116,6 +156,10 @@ const styles = StyleSheet.create({
116
156
  flex: 1,
117
157
  },
118
158
  share: {
119
- width: 36,
159
+ width: 30,
160
+ },
161
+ toggle: {
162
+ gap: 4,
163
+ minHeight: 32,
120
164
  },
121
165
  });
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
- });