@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 +22 -16
- package/examples/tpl_assistant.tsx +4 -4
- package/examples/tpl_lookup.tsx +4 -4
- package/examples/tpl_stock.tsx +9 -1
- package/package.json +1 -2
- package/src/agent_progress.tsx +5 -5
- package/src/agent_run.tsx +1 -1
- package/src/breakdown.tsx +48 -4
- 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
|
@@ -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** — `
|
|
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** — `
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
the
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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**: `
|
|
320
|
-
|
|
321
|
-
|
|
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) ·
|
|
520
|
-
|
|
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 {
|
|
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/examples/tpl_stock.tsx
CHANGED
|
@@ -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",
|
|
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.
|
|
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",
|
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/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
|
-
{
|
|
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:
|
|
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:
|
|
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
|
-
/**
|
|
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
|
-
});
|