@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.
- package/AGENTS.md +79 -6
- package/examples/tpl_assistant.tsx +141 -0
- package/examples/tpl_dieline.tsx +252 -0
- package/examples/tpl_draft.tsx +165 -0
- package/examples/tpl_extract.tsx +186 -0
- package/package.json +15 -2
- package/src/agent_progress.tsx +71 -0
- package/src/agent_run.tsx +194 -0
- package/src/assisted_field.tsx +93 -0
- package/src/change_review.tsx +166 -0
- package/src/clarify.tsx +68 -0
- package/src/confidence.tsx +31 -0
- package/src/inline_edit.tsx +12 -5
- package/src/inline_member_select.tsx +62 -0
- package/src/inline_select.tsx +6 -2
- package/src/prompt_field.tsx +150 -0
- package/src/sources.tsx +92 -0
- package/src/suggestion.tsx +102 -0
|
@@ -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
|
+
});
|
package/src/clarify.tsx
ADDED
|
@@ -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
|
+
}
|
package/src/inline_edit.tsx
CHANGED
|
@@ -102,8 +102,10 @@ interface InlineEditFrameProps {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
interface InlineEditViewProps {
|
|
105
|
-
/** The
|
|
106
|
-
|
|
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
|
-
|
|
140
|
-
{display
|
|
141
|
-
|
|
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
|
+
}
|
package/src/inline_select.tsx
CHANGED
|
@@ -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
|
|
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
|
+
});
|