@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 +19 -15
- package/examples/tpl_assistant.tsx +4 -4
- package/examples/tpl_lookup.tsx +4 -4
- package/package.json +1 -2
- package/src/agent_progress.tsx +5 -5
- package/src/agent_run.tsx +1 -1
- package/src/composer.tsx +157 -67
- package/src/use_auto_grow_height.ts +4 -0
- package/src/prompt_field.tsx +0 -108
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** — `
|
|
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** — `
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
the
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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**: `
|
|
320
|
-
|
|
321
|
-
|
|
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) ·
|
|
520
|
-
|
|
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 {
|
|
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
|
-
<
|
|
130
|
+
<Composer
|
|
131
131
|
value={prompt}
|
|
132
132
|
onChangeText={setPrompt}
|
|
133
|
-
|
|
134
|
-
|
|
133
|
+
onSend={send}
|
|
134
|
+
disabled={busy}
|
|
135
135
|
placeholder={proposed ? "Ask for another change…" : "Try: “Tidy up this contact”"}
|
|
136
136
|
/>
|
|
137
137
|
</View>
|
package/examples/tpl_lookup.tsx
CHANGED
|
@@ -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 {
|
|
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
|
-
<
|
|
223
|
+
<Composer
|
|
224
224
|
value={prompt}
|
|
225
225
|
onChangeText={setPrompt}
|
|
226
|
-
|
|
227
|
-
|
|
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.
|
|
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",
|
package/src/agent_progress.tsx
CHANGED
|
@@ -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 (`
|
|
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
|
|
60
|
-
// ph 6/left-padded for the avatar) so the composer→progress swap
|
|
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 `
|
|
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
|
-
/**
|
|
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({
|
|
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
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
{
|
|
137
|
-
|
|
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
|
-
|
|
167
|
-
<View style={styles.
|
|
168
|
-
|
|
169
|
-
{footerRight}
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/prompt_field.tsx
DELETED
|
@@ -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
|
-
});
|