@lotics/ui 4.2.0 → 4.4.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.
@@ -0,0 +1,93 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { solid, tint } from "./colors";
3
+ import { Text } from "./text";
4
+ import { Icon } from "./icon";
5
+ import { IconButton } from "./icon_button";
6
+ import { LinkButton } from "./link_button";
7
+ import { Confidence, type ConfidenceLevel } from "./confidence";
8
+
9
+ export interface AssistedFieldProps {
10
+ label: string;
11
+ /** The current value — an AI estimate until confirmed, or the final value. */
12
+ value: string;
13
+ /** Unit suffix shown after the value (mm, cm, kg). */
14
+ unit?: string;
15
+ confidence?: ConfidenceLevel;
16
+ confidenceScore?: number;
17
+ /** True while the value is still an unconfirmed AI estimate — it reads
18
+ * amber-tinted with a Confidence chip and a Confirm action (visible
19
+ * provenance). Confirm or override flips it to a plain confirmed value. */
20
+ estimated?: boolean;
21
+ onConfirm?: () => void;
22
+ /** Override — the host swaps in its own inline input to edit the value. */
23
+ onEdit?: () => void;
24
+ }
25
+
26
+ /**
27
+ * A form value the AI pre-filled — shown as a tinted ESTIMATE the human
28
+ * confirms (→ plain, checked) or overrides, never silently committed. The
29
+ * provenance is always visible: an estimate looks different from a value a
30
+ * person stood behind. Pair with `Confidence`; for a whole extracted record,
31
+ * stack several and add one "Confirm all" at the form level.
32
+ */
33
+ export function AssistedField(props: AssistedFieldProps) {
34
+ const { label, value, unit, estimated, confidence, confidenceScore, onConfirm, onEdit } = props;
35
+ const display = unit ? `${value} ${unit}` : value;
36
+ return (
37
+ <View style={styles.row}>
38
+ <Text size="sm" color="muted" style={styles.label}>
39
+ {label}
40
+ </Text>
41
+ <View style={styles.valueCol}>
42
+ <View style={styles.valueRow}>
43
+ {estimated ? (
44
+ <View style={styles.estimatePill}>
45
+ <Icon name="sparkles" size={11} color={solid("violet")} />
46
+ <Text size="sm" weight="medium" tabular>
47
+ {display}
48
+ </Text>
49
+ </View>
50
+ ) : (
51
+ <View style={styles.confirmedRow}>
52
+ <Text size="sm" weight="medium" tabular>
53
+ {display}
54
+ </Text>
55
+ <Icon name="circle-check" size={13} color={solid("emerald")} />
56
+ </View>
57
+ )}
58
+ {onEdit ? (
59
+ <IconButton icon="square-pen" size="sm" color="none" accessibilityLabel={`Edit ${label}`} onPress={onEdit} />
60
+ ) : null}
61
+ </View>
62
+ {estimated ? (
63
+ <View style={styles.estimateMeta}>
64
+ {confidence != null || confidenceScore != null ? (
65
+ <Confidence level={confidence} score={confidenceScore} />
66
+ ) : null}
67
+ {onConfirm ? <LinkButton title="Confirm" onPress={onConfirm} /> : null}
68
+ </View>
69
+ ) : null}
70
+ </View>
71
+ </View>
72
+ );
73
+ }
74
+
75
+ const styles = StyleSheet.create({
76
+ row: { flexDirection: "row", alignItems: "flex-start", gap: 16, paddingVertical: 8 },
77
+ label: { width: 132, paddingTop: 6 },
78
+ valueCol: { flex: 1, gap: 6, alignItems: "flex-end" },
79
+ valueRow: { flexDirection: "row", alignItems: "center", gap: 6 },
80
+ estimatePill: {
81
+ flexDirection: "row",
82
+ alignItems: "center",
83
+ gap: 6,
84
+ paddingHorizontal: 10,
85
+ paddingVertical: 5,
86
+ borderRadius: 8,
87
+ backgroundColor: tint("amber", 0.12),
88
+ borderWidth: 1,
89
+ borderColor: tint("amber", 0.35),
90
+ },
91
+ confirmedRow: { flexDirection: "row", alignItems: "center", gap: 6, paddingVertical: 5 },
92
+ estimateMeta: { flexDirection: "row", alignItems: "center", gap: 10 },
93
+ });
@@ -0,0 +1,166 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { colors, solid, tint } from "./colors";
3
+ import { Text } from "./text";
4
+ import { Icon } from "./icon";
5
+ import { Button } from "./button";
6
+ import { IconButton } from "./icon_button";
7
+ import { LinkButton } from "./link_button";
8
+
9
+ export type ChangeReviewItemStatus = "pending" | "accepted" | "rejected";
10
+
11
+ export interface ChangeReviewItem {
12
+ /** What changed — a field/parameter name. */
13
+ label: string;
14
+ /** Prior value. Omit for a newly-added value (renders as just the new value). */
15
+ before?: string;
16
+ /** Proposed value. */
17
+ after: string;
18
+ /** Per-item decision — only meaningful in the 1-by-1 mode (when the host
19
+ * passes `onAcceptItem`/`onRejectItem`). */
20
+ status?: ChangeReviewItemStatus;
21
+ }
22
+
23
+ export interface ChangeReviewProps {
24
+ changes: ChangeReviewItem[];
25
+ /** The instruction that produced the edit ("make it 5 mm taller"). */
26
+ summary?: string;
27
+ onApply?: () => void;
28
+ onDiscard?: () => void;
29
+ applyLabel?: string;
30
+ /** Once resolved the card settles to a quiet outcome line (no actions). */
31
+ status?: "open" | "applied" | "discarded";
32
+ /** Providing either flips the card into 1-BY-1 mode: each change row gets its
33
+ * own ✓/✕ and reflects `item.status`; the footer counts the accepted set and
34
+ * Apply commits only those. The "review the agent's suggested edits one by
35
+ * one" pattern. Omit both for whole-set apply/discard. */
36
+ onAcceptItem?: (index: number) => void;
37
+ onRejectItem?: (index: number) => void;
38
+ }
39
+
40
+ /**
41
+ * An agent-proposed edit to existing state, shown as a before→after diff the
42
+ * human reviews before it lands — never auto-applied. Two modes: whole-set
43
+ * (apply / discard the lot) or, when the host passes `onAcceptItem`/
44
+ * `onRejectItem`, 1-BY-1 — each suggested change accepted or rejected on its
45
+ * own, the footer applying only the accepted set. Where `Suggestion` proposes
46
+ * a NEW value, this reviews CHANGES to existing ones.
47
+ */
48
+ export function ChangeReview(props: ChangeReviewProps) {
49
+ const status = props.status ?? "open";
50
+ const perItem = props.onAcceptItem != null || props.onRejectItem != null;
51
+ const accepted = props.changes.filter((c) => c.status === "accepted").length;
52
+
53
+ return (
54
+ <View style={[styles.card, status !== "open" ? styles.resolved : null]}>
55
+ {props.summary ? (
56
+ <View style={styles.summary}>
57
+ <Icon name="sparkles" size={13} color={solid("violet")} />
58
+ <Text size="sm" weight="medium" style={{ flex: 1 }}>
59
+ {props.summary}
60
+ </Text>
61
+ </View>
62
+ ) : null}
63
+
64
+ <View style={styles.changes}>
65
+ {props.changes.map((c, i) => {
66
+ const rejected = c.status === "rejected";
67
+ const ok = c.status === "accepted";
68
+ return (
69
+ <View key={`${c.label}-${i}`} style={styles.change}>
70
+ <Text size="xs" color="muted" transform="uppercase" style={styles.changeLabel}>
71
+ {c.label}
72
+ </Text>
73
+ <View style={styles.diff}>
74
+ {c.before != null ? (
75
+ <>
76
+ <Text size="sm" color="muted" tabular style={styles.before}>
77
+ {c.before}
78
+ </Text>
79
+ <Icon name="arrow-right" size={13} color={colors.zinc[400]} />
80
+ </>
81
+ ) : null}
82
+ <Text size="sm" weight="medium" tabular color={rejected ? "muted" : "default"} style={rejected ? styles.before : undefined}>
83
+ {c.after}
84
+ </Text>
85
+ </View>
86
+ {perItem && status === "open" ? (
87
+ <View style={styles.itemControls}>
88
+ <IconButton
89
+ icon="check"
90
+ size="sm"
91
+ color="none"
92
+ iconColor={ok ? solid("emerald") : colors.zinc[400]}
93
+ accessibilityLabel={`Accept — ${c.label}`}
94
+ onPress={() => props.onAcceptItem?.(i)}
95
+ />
96
+ <IconButton
97
+ icon="x"
98
+ size="sm"
99
+ color="none"
100
+ iconColor={rejected ? solid("red") : colors.zinc[400]}
101
+ accessibilityLabel={`Reject — ${c.label}`}
102
+ onPress={() => props.onRejectItem?.(i)}
103
+ />
104
+ </View>
105
+ ) : null}
106
+ </View>
107
+ );
108
+ })}
109
+ </View>
110
+
111
+ {status !== "open" ? (
112
+ <View style={styles.outcome}>
113
+ <Icon
114
+ name={status === "applied" ? "circle-check" : "rotate-ccw"}
115
+ size={14}
116
+ color={status === "applied" ? solid("emerald") : colors.zinc[400]}
117
+ />
118
+ <Text size="xs" color="muted">
119
+ {status === "applied" ? "Applied" : "Discarded"}
120
+ </Text>
121
+ </View>
122
+ ) : (
123
+ <View style={styles.footer}>
124
+ <View style={{ flex: 1 }}>
125
+ {perItem ? (
126
+ <Text size="xs" color="muted">
127
+ {accepted} of {props.changes.length} accepted
128
+ </Text>
129
+ ) : null}
130
+ </View>
131
+ {props.onDiscard ? <LinkButton title={perItem ? "Reject all" : "Discard"} onPress={props.onDiscard} /> : null}
132
+ {props.onApply ? (
133
+ <Button
134
+ title={props.applyLabel ?? (perItem ? "Apply accepted" : "Apply")}
135
+ color="primary"
136
+ shape="rounded"
137
+ disabled={perItem && accepted === 0}
138
+ onPress={props.onApply}
139
+ />
140
+ ) : null}
141
+ </View>
142
+ )}
143
+ </View>
144
+ );
145
+ }
146
+
147
+ const styles = StyleSheet.create({
148
+ card: {
149
+ borderWidth: 1,
150
+ borderColor: tint("violet", 0.35),
151
+ backgroundColor: colors.white,
152
+ borderRadius: 12,
153
+ padding: 14,
154
+ gap: 12,
155
+ },
156
+ resolved: { borderColor: colors.border, backgroundColor: colors.zinc[50] },
157
+ summary: { flexDirection: "row", alignItems: "center", gap: 8 },
158
+ changes: { gap: 8 },
159
+ change: { flexDirection: "row", alignItems: "center", gap: 12 },
160
+ changeLabel: { width: 120 },
161
+ diff: { flexDirection: "row", alignItems: "center", gap: 8, flex: 1 },
162
+ before: { textDecorationLine: "line-through" },
163
+ itemControls: { flexDirection: "row", alignItems: "center", gap: 2 },
164
+ outcome: { flexDirection: "row", alignItems: "center", gap: 6 },
165
+ footer: { flexDirection: "row", alignItems: "center", gap: 8 },
166
+ });
@@ -0,0 +1,68 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { colors, solid, tint } from "./colors";
3
+ import { Text } from "./text";
4
+ import { Icon } from "./icon";
5
+ import { Button } from "./button";
6
+
7
+ export interface ClarifyOption {
8
+ label: string;
9
+ value: string;
10
+ }
11
+
12
+ export interface ClarifyProps {
13
+ /** The agent's question — what it needs settled to proceed. */
14
+ question: string;
15
+ options: ClarifyOption[];
16
+ onAnswer: (value: string) => void;
17
+ /** Once answered, the card settles showing the chosen reply (pass the option
18
+ * label). */
19
+ answer?: string;
20
+ }
21
+
22
+ /**
23
+ * The agent asks BACK — a question with quick-reply options the human picks
24
+ * before the run continues. Human-in-the-loop input: when the agent is unsure,
25
+ * it clarifies instead of guessing wrong. The run pauses on this; `onAnswer`
26
+ * resumes it. Pair with `AgentRun` / `AgentProgress`.
27
+ */
28
+ export function Clarify(props: ClarifyProps) {
29
+ const answered = props.answer != null;
30
+ return (
31
+ <View style={styles.card}>
32
+ <View style={styles.header}>
33
+ <Icon name="message-circle-question-mark" size={15} color={solid("violet")} />
34
+ <Text size="sm" weight="medium" style={{ flex: 1 }}>
35
+ {props.question}
36
+ </Text>
37
+ </View>
38
+ {answered ? (
39
+ <View style={styles.answered}>
40
+ <Icon name="circle-check" size={14} color={solid("emerald")} />
41
+ <Text size="sm" color="muted">
42
+ {props.answer}
43
+ </Text>
44
+ </View>
45
+ ) : (
46
+ <View style={styles.options}>
47
+ {props.options.map((o) => (
48
+ <Button key={o.value} title={o.label} color="secondary" shape="rounded" onPress={() => props.onAnswer(o.value)} />
49
+ ))}
50
+ </View>
51
+ )}
52
+ </View>
53
+ );
54
+ }
55
+
56
+ const styles = StyleSheet.create({
57
+ card: {
58
+ borderWidth: 1,
59
+ borderColor: tint("violet", 0.35),
60
+ backgroundColor: colors.white,
61
+ borderRadius: 12,
62
+ padding: 14,
63
+ gap: 12,
64
+ },
65
+ header: { flexDirection: "row", alignItems: "flex-start", gap: 8 },
66
+ options: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
67
+ answered: { flexDirection: "row", alignItems: "center", gap: 6 },
68
+ });
@@ -0,0 +1,31 @@
1
+ import { Badge } from "./badge";
2
+ import type { ColorName } from "./colors";
3
+
4
+ export type ConfidenceLevel = "high" | "medium" | "low";
5
+
6
+ export interface ConfidenceProps {
7
+ /** Pass a level directly, or a 0–1 `score` (≥0.8 high · ≥0.5 medium · else low). */
8
+ level?: ConfidenceLevel;
9
+ score?: number;
10
+ }
11
+
12
+ const LABEL: Record<ConfidenceLevel, string> = { high: "High", medium: "Medium", low: "Low" };
13
+ // low is zinc (unsure, NOT an error — an error is a different signal); high
14
+ // emerald, medium amber. One family per level, weights derive from the name.
15
+ const COLOR: Record<ConfidenceLevel, ColorName> = { high: "emerald", medium: "amber", low: "zinc" };
16
+
17
+ export function levelFromScore(score: number): ConfidenceLevel {
18
+ if (score >= 0.8) return "high";
19
+ if (score >= 0.5) return "medium";
20
+ return "low";
21
+ }
22
+
23
+ /**
24
+ * How sure the AI is — a calibrated high/medium/low chip on the kit's `Badge`
25
+ * (dot variant). The human weights an AI proposal or estimate by it before
26
+ * accepting. Pair with `Suggestion` and `AssistedField`.
27
+ */
28
+ export function Confidence(props: ConfidenceProps) {
29
+ const level = props.level ?? (props.score != null ? levelFromScore(props.score) : "medium");
30
+ return <Badge variant="dot" color={COLOR[level]} label={LABEL[level]} />;
31
+ }
@@ -102,8 +102,10 @@ interface InlineEditFrameProps {
102
102
  }
103
103
 
104
104
  interface InlineEditViewProps {
105
- /** The formatted current value. Empty → placeholder. */
106
- display: string;
105
+ /** The current value: a formatted string (empty → placeholder), or a node
106
+ * rendered as-is — e.g. a `MemberChip` / `OptionBadge` for a rich resting
107
+ * value. A node takes the flex slot; the trailing adornment still right-aligns. */
108
+ display: string | ReactNode;
107
109
  placeholder?: string;
108
110
  onPress?: () => void;
109
111
  disabled?: boolean;
@@ -136,9 +138,13 @@ export function InlineEditView(props: InlineEditViewProps) {
136
138
  userSelect="none"
137
139
  style={[styles.view, active && styles.viewActive]}
138
140
  >
139
- <Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder]}>
140
- {display || placeholder || "—"}
141
- </Text>
141
+ {typeof display === "string" ? (
142
+ <Text numberOfLines={1} style={[viewTextStyle, display ? null : styles.placeholder]}>
143
+ {display || placeholder || "—"}
144
+ </Text>
145
+ ) : (
146
+ <View style={styles.viewNode}>{display}</View>
147
+ )}
142
148
  {trailing != null ? trailing : null}
143
149
  </PressableHighlight>
144
150
  );
@@ -230,6 +236,7 @@ const styles = StyleSheet.create({
230
236
  boxShadow: ACTIVE_RING,
231
237
  },
232
238
  placeholder: { color: colors.zinc[400] },
239
+ viewNode: { flex: 1, minWidth: 0 },
233
240
  editRow: { flexDirection: "row", alignItems: "flex-start", gap: 6 },
234
241
  editControl: { flex: 1, position: "relative" },
235
242
  savingOverlay: { position: "absolute", right: 8, top: 0, bottom: 0, justifyContent: "center" },
@@ -0,0 +1,62 @@
1
+ import { useCallback, useMemo } from "react";
2
+ import { InlineSelect } from "./inline_select";
3
+ import { MemberChip } from "./member_chip";
4
+ import type { MemberSelectMember } from "./member_select";
5
+ import type { PickerOption } from "./picker";
6
+
7
+ interface InlineMemberSelectProps {
8
+ /** The candidate roster — an app feeds `useMembers()`, the product any
9
+ * directory. Each member carries its own avatar (`image`), so the resting
10
+ * chip and every option render with no side lookup. */
11
+ members: MemberSelectMember[];
12
+ /** The currently-assigned member id, or `null` when unassigned. A value not
13
+ * in `members` (a removed/deactivated member) reads as empty → placeholder. */
14
+ value: string | null;
15
+ /** Commit a new assignment (the picked member id). Throwing surfaces the
16
+ * inline error and reverts, like every inline editor. */
17
+ onSave: (memberId: string) => void | Promise<void>;
18
+ placeholder?: string;
19
+ disabled?: boolean;
20
+ accessibilityLabel?: string;
21
+ }
22
+
23
+ /**
24
+ * Edit a `select_member` field in place: the assigned member shows as a
25
+ * {@link MemberChip} (avatar + name) at the kit's control height; clicking it
26
+ * floats a member picker (every option a `MemberChip`) and picking commits — no
27
+ * layout shift. The inline-edit twin of {@link MemberSelect} (use that in a
28
+ * form); built on {@link InlineSelect}. PURE: pass the candidate `members` (an
29
+ * app feeds `useMembers`); this fetches nothing.
30
+ *
31
+ * ```tsx
32
+ * const { members } = useMembers();
33
+ * <InlineMemberSelect members={members} value={row.assignee} onSave={saveAssignee} />
34
+ * ```
35
+ */
36
+ export function InlineMemberSelect(props: InlineMemberSelectProps) {
37
+ const { members, ...inline } = props;
38
+
39
+ const byId = useMemo(() => new Map(members.map((m) => [m.id, m])), [members]);
40
+ const options = useMemo<PickerOption<string>[]>(
41
+ () => members.map((m) => ({ value: m.id, label: m.name || m.email || m.id })),
42
+ [members],
43
+ );
44
+
45
+ const renderMember = useCallback(
46
+ (option: PickerOption<string>) => {
47
+ const member = byId.get(option.value);
48
+ if (!member) return null;
49
+ return <MemberChip name={member.name || member.email} image={member.image} />;
50
+ },
51
+ [byId],
52
+ );
53
+
54
+ return (
55
+ <InlineSelect<string>
56
+ options={options}
57
+ renderOptionContent={renderMember}
58
+ renderValue={renderMember}
59
+ {...inline}
60
+ />
61
+ );
62
+ }
@@ -16,6 +16,10 @@ export interface InlineSelectProps<T extends string> {
16
16
  /** Custom option content in the dropdown (icon + label, two-line, a badge…).
17
17
  * Omit for a plain label list — both render through the same `PickerMenu`. */
18
18
  renderOptionContent?: (option: PickerOption<T>) => React.ReactNode;
19
+ /** Render the SELECTED value in the resting view as a node (an avatar chip, a
20
+ * colored badge) instead of its plain label. Falls back to the label when
21
+ * omitted; the empty state always shows the placeholder. */
22
+ renderValue?: (selected: PickerOption<T>) => React.ReactNode;
19
23
  placeholder?: string;
20
24
  disabled?: boolean;
21
25
  accessibilityLabel?: string;
@@ -29,7 +33,7 @@ export interface InlineSelectProps<T extends string> {
29
33
  * standard and custom-rendered pickers are both supported here.
30
34
  */
31
35
  export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
32
- const { value, onSave, options, renderOptionContent, placeholder, disabled, accessibilityLabel } = props;
36
+ const { value, onSave, options, renderOptionContent, renderValue, placeholder, disabled, accessibilityLabel } = props;
33
37
  const [open, setOpen] = useState(false);
34
38
  const [saving, setSaving] = useState(false);
35
39
  const [error, setError] = useState<string | null>(null);
@@ -58,7 +62,7 @@ export function InlineSelect<T extends string>(props: InlineSelectProps<T>) {
58
62
  <Popover open={open && !disabled} onOpenChange={setOpen} side="bottom" align="start" inheritTriggerWidth>
59
63
  <PopoverTrigger>
60
64
  <InlineEditView
61
- display={selected?.label ?? ""}
65
+ display={selected ? (renderValue ? renderValue(selected) : (selected.label ?? "")) : ""}
62
66
  placeholder={placeholder}
63
67
  disabled={disabled}
64
68
  active={open && !disabled}
@@ -0,0 +1,150 @@
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, solid, tint } from "./colors";
10
+ import { Icon } from "./icon";
11
+ import { IconButton } from "./icon_button";
12
+ import { ActivityIndicator } from "./activity_indicator";
13
+ import { ACTIVE_RING } from "./control_surface";
14
+ import { getInputTextStyle, fontFamilyRegular } from "./text_utils";
15
+
16
+ export interface PromptFieldProps {
17
+ value: string;
18
+ onChangeText: (value: string) => void;
19
+ /** Fired on Enter (without Shift) or the send button. Receives the trimmed
20
+ * text; never fires empty or while busy. */
21
+ onSubmit: (value: string) => void;
22
+ placeholder?: string;
23
+ /** The agent is working — the field locks and the send slot shows a spinner.
24
+ * This is the imperative command surface: one prompt does work, you watch it
25
+ * stream (see `AgentRun`), you don't chat. */
26
+ busy?: boolean;
27
+ disabled?: boolean;
28
+ autoFocus?: boolean;
29
+ accessibilityLabel?: string;
30
+ /** When set, a paperclip appears at the left of the footer — the composer can
31
+ * attach files (e.g. the photo that starts a run). The host opens the picker. */
32
+ onAttach?: () => void;
33
+ }
34
+
35
+ /**
36
+ * The natural-language command surface for an AI app — a prompt box that
37
+ * triggers agent work, NOT a chat composer. Enter sends (Shift+Enter for a
38
+ * newline); while the agent runs, the field locks and the send slot spins. The
39
+ * violet sparkle marks it as the AI input. Pair with `AgentRun` (the streamed
40
+ * work it kicks off) and a session history of the outputs it produces.
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
+ // Web: Enter sends, Shift+Enter inserts a newline. RN's key event wraps the
54
+ // DOM event, so shiftKey/preventDefault are read defensively off nativeEvent.
55
+ const onKeyPress = useCallback(
56
+ (e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
57
+ const ne = e.nativeEvent as unknown as { key: string; shiftKey?: boolean };
58
+ if (ne.key === "Enter" && !ne.shiftKey) {
59
+ (e as unknown as { preventDefault?: () => void }).preventDefault?.();
60
+ submit();
61
+ }
62
+ },
63
+ [submit],
64
+ );
65
+
66
+ return (
67
+ <View
68
+ style={[
69
+ styles.box,
70
+ focused ? { borderColor: colors.zinc[900], boxShadow: ACTIVE_RING } : null,
71
+ disabled ? { backgroundColor: colors.zinc[50] } : null,
72
+ ]}
73
+ >
74
+ <View style={styles.inputRow}>
75
+ <View style={styles.glyph}>
76
+ <Icon name="sparkles" size={14} color={solid("violet")} />
77
+ </View>
78
+ <RNTextInput
79
+ accessibilityLabel={accessibilityLabel ?? placeholder}
80
+ style={[styles.input, getInputTextStyle()]}
81
+ value={value}
82
+ onChangeText={onChangeText}
83
+ onKeyPress={onKeyPress}
84
+ onFocus={() => setFocused(true)}
85
+ onBlur={() => setFocused(false)}
86
+ placeholder={placeholder}
87
+ placeholderTextColor={colors.zinc[400]}
88
+ editable={!busy && !disabled}
89
+ autoFocus={autoFocus}
90
+ multiline
91
+ submitBehavior="newline"
92
+ />
93
+ </View>
94
+ <View style={styles.footer}>
95
+ {onAttach ? (
96
+ <IconButton icon="paperclip" color="none" size="sm" accessibilityLabel="Attach files" onPress={onAttach} disabled={busy || disabled} />
97
+ ) : null}
98
+ <View style={{ flex: 1 }} />
99
+ {busy ? (
100
+ <View style={styles.sendSlot}>
101
+ <ActivityIndicator size={18} color={solid("violet")} />
102
+ </View>
103
+ ) : (
104
+ <IconButton
105
+ icon="arrow-up"
106
+ color="primary"
107
+ accessibilityLabel="Send"
108
+ onPress={submit}
109
+ disabled={!canSubmit}
110
+ />
111
+ )}
112
+ </View>
113
+ </View>
114
+ );
115
+ }
116
+
117
+ const styles = StyleSheet.create({
118
+ box: {
119
+ borderWidth: 1,
120
+ borderColor: colors.border,
121
+ borderRadius: 14,
122
+ backgroundColor: colors.white,
123
+ paddingHorizontal: 12,
124
+ paddingTop: 10,
125
+ paddingBottom: 8,
126
+ gap: 6,
127
+ },
128
+ inputRow: { flexDirection: "row", gap: 8, alignItems: "flex-start" },
129
+ glyph: {
130
+ width: 24,
131
+ height: 24,
132
+ borderRadius: 7,
133
+ alignItems: "center",
134
+ justifyContent: "center",
135
+ backgroundColor: tint("violet", 0.1),
136
+ marginTop: 2,
137
+ },
138
+ input: {
139
+ flex: 1,
140
+ minHeight: 48,
141
+ maxHeight: 160,
142
+ paddingTop: 4,
143
+ paddingBottom: 0,
144
+ fontFamily: fontFamilyRegular,
145
+ // RN-Web focus outline is handled by the box ring, not the raw input.
146
+ outlineStyle: "none",
147
+ } as object,
148
+ footer: { flexDirection: "row", alignItems: "center" },
149
+ sendSlot: { width: 28, height: 28, alignItems: "center", justifyContent: "center" },
150
+ });