@lotics/ui 4.4.0 → 4.5.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.
@@ -1,17 +1,15 @@
1
1
  import { StyleSheet, View } from "react-native";
2
- import { colors, solid, tint } from "./colors";
2
+ import { colors } from "./colors";
3
3
  import { Text } from "./text";
4
4
  import { Icon } from "./icon";
5
5
  import { Button } from "./button";
6
- import { IconButton } from "./icon_button";
7
- import { LinkButton } from "./link_button";
8
6
 
9
7
  export type ChangeReviewItemStatus = "pending" | "accepted" | "rejected";
10
8
 
11
9
  export interface ChangeReviewItem {
12
10
  /** What changed — a field/parameter name. */
13
11
  label: string;
14
- /** Prior value. Omit for a newly-added value (renders as just the new value). */
12
+ /** Prior value. Omit for a newly-added value (renders as just the `+` line). */
15
13
  before?: string;
16
14
  /** Proposed value. */
17
15
  after: string;
@@ -29,114 +27,179 @@ export interface ChangeReviewProps {
29
27
  applyLabel?: string;
30
28
  /** Once resolved the card settles to a quiet outcome line (no actions). */
31
29
  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. */
30
+ /** Providing either flips the card into 1-BY-1 mode: each edit is its own
31
+ * card with Keep / Drop; deciding COLLAPSES it to a preview the host can
32
+ * `onUndoItem`. "Accept all" + "Apply kept" sit in a separate footer. The
33
+ * decision is recorded — nothing writes until Apply. Omit both for whole-set. */
36
34
  onAcceptItem?: (index: number) => void;
37
35
  onRejectItem?: (index: number) => void;
36
+ /** Revert a decided item back to pending (the Undo on a collapsed card). */
37
+ onUndoItem?: (index: number) => void;
38
+ }
39
+
40
+ /** A git-style stacked diff: the old value on a red `−` line (struck), the new
41
+ * value on a green `+` line. An addition (no `before`) is just the `+` line. */
42
+ function DiffLines({ before, after }: { before?: string; after: string }) {
43
+ return (
44
+ <View style={styles.diff}>
45
+ {before != null ? (
46
+ <View style={styles.diffLine}>
47
+ <Text size="sm" weight="medium" tabular style={styles.marker(colors.red[600])}>
48
+
49
+ </Text>
50
+ <Text size="sm" tabular style={[styles.strike, styles.value(colors.red[600])]}>
51
+ {before}
52
+ </Text>
53
+ </View>
54
+ ) : null}
55
+ <View style={styles.diffLine}>
56
+ <Text size="sm" weight="medium" tabular style={styles.marker(colors.emerald[700])}>
57
+ +
58
+ </Text>
59
+ <Text size="sm" weight="medium" tabular style={styles.value(colors.emerald[700])}>
60
+ {after}
61
+ </Text>
62
+ </View>
63
+ </View>
64
+ );
38
65
  }
39
66
 
40
67
  /**
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.
68
+ * An agent-proposed edit to existing state, reviewed before it lands never
69
+ * auto-applied. Two modes. Whole-set: a list of before→after diffs + apply /
70
+ * discard the lot. 1-BY-1 (when the host passes `onAcceptItem`/`onRejectItem`):
71
+ * each edit is its own card with Keep / Drop; deciding COLLAPSES the card to a
72
+ * preview with Undo, and a separate footer carries Accept-all + the single Apply
73
+ * that commits the kept set. Where `Suggestion` proposes a NEW value, this
74
+ * reviews CHANGES to existing ones.
47
75
  */
48
76
  export function ChangeReview(props: ChangeReviewProps) {
49
77
  const status = props.status ?? "open";
50
78
  const perItem = props.onAcceptItem != null || props.onRejectItem != null;
51
- const accepted = props.changes.filter((c) => c.status === "accepted").length;
79
+ const total = props.changes.length;
80
+ const keptCount = props.changes.filter((c) => c.status === "accepted").length;
52
81
 
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}
82
+ const acceptAll = () =>
83
+ props.changes.forEach((c, i) => {
84
+ if ((c.status ?? "pending") === "pending") props.onAcceptItem?.(i);
85
+ });
86
+
87
+ const summaryNote = props.summary ? (
88
+ <View style={styles.note}>
89
+ <Text size="sm" color="muted">
90
+ {props.summary}
91
+ </Text>
92
+ </View>
93
+ ) : null;
94
+
95
+ // ── 1-BY-1: each edit a card; deciding collapses it to a preview ──────────
96
+ if (perItem) {
97
+ return (
98
+ <View style={[styles.card, status !== "open" ? styles.resolved : null]}>
99
+ <View style={styles.head}>
100
+ <Text size="xs" color="muted" weight="medium" style={{ flex: 1 }}>
101
+ Suggested edits
60
102
  </Text>
103
+ {status === "open" ? (
104
+ <Text size="xs" color="muted" tabular>
105
+ {keptCount} of {total} kept
106
+ </Text>
107
+ ) : null}
61
108
  </View>
62
- ) : null}
109
+ {summaryNote}
63
110
 
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}
111
+ {status !== "open" ? (
112
+ <Text size="xs" color="muted" weight="medium">
113
+ {status === "applied" ? "Applied" : "Discarded"}
114
+ </Text>
115
+ ) : (
116
+ <>
117
+ <View style={styles.list}>
118
+ {props.changes.map((c, i) => {
119
+ const st = c.status ?? "pending";
120
+ if (st === "pending") {
121
+ return (
122
+ <View key={`${c.label}-${i}`} style={styles.block}>
123
+ <Text size="xs" color="muted" weight="medium">
124
+ {c.label}
125
+ </Text>
126
+ <DiffLines before={c.before} after={c.after} />
127
+ <View style={styles.cardActions}>
128
+ <Button title="Drop" color="muted" shape="rounded" onPress={() => props.onRejectItem?.(i)} />
129
+ <Button title="Keep" color="secondary" shape="rounded" onPress={() => props.onAcceptItem?.(i)} />
130
+ </View>
131
+ </View>
132
+ );
133
+ }
134
+ const kept = st === "accepted";
135
+ return (
136
+ <View key={`${c.label}-${i}`} style={styles.collapsed}>
137
+ <Text size="sm" weight="medium" numberOfLines={1}>
138
+ {c.label}
78
139
  </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>
140
+ <Text
141
+ size="sm"
142
+ color={kept ? "default" : "muted"}
143
+ numberOfLines={1}
144
+ style={[{ flex: 1 }, kept ? null : styles.strike]}
145
+ >
146
+ {kept ? c.after : c.before ?? c.after}
147
+ </Text>
148
+ <View style={styles.statusTag}>
149
+ <Icon name={kept ? "check" : "x"} size={14} color={kept ? colors.emerald[600] : colors.zinc[400]} />
150
+ <Text size="sm" weight="medium" color="muted">
151
+ {kept ? "Kept" : "Dropped"}
152
+ </Text>
153
+ </View>
154
+ {props.onUndoItem ? (
155
+ <Button title="Undo" color="muted" shape="rounded" onPress={() => props.onUndoItem?.(i)} />
156
+ ) : null}
157
+ </View>
158
+ );
159
+ })}
160
+ </View>
161
+
162
+ <View style={styles.footer}>
163
+ <Button title="Accept all" color="muted" shape="rounded" onPress={acceptAll} />
164
+ <View style={{ flex: 1 }} />
165
+ {props.onDiscard ? <Button title="Discard" color="muted" shape="rounded" onPress={props.onDiscard} /> : null}
166
+ {props.onApply ? (
167
+ <Button title={props.applyLabel ?? "Apply kept"} color="primary" shape="rounded" disabled={keptCount === 0} onPress={props.onApply} />
105
168
  ) : null}
106
169
  </View>
107
- );
108
- })}
170
+ </>
171
+ )}
109
172
  </View>
173
+ );
174
+ }
110
175
 
176
+ // ── WHOLE-SET: review the lot, apply / discard together ───────────────────
177
+ return (
178
+ <View style={[styles.card, status !== "open" ? styles.resolved : null]}>
179
+ <Text size="xs" color="muted" weight="medium">
180
+ Suggested edit
181
+ </Text>
182
+ {summaryNote}
183
+ <View style={styles.block}>
184
+ {props.changes.map((c, i) => (
185
+ <View key={`${c.label}-${i}`} style={i > 0 ? styles.changeSpacer : undefined}>
186
+ <Text size="xs" color="muted" weight="medium">
187
+ {c.label}
188
+ </Text>
189
+ <DiffLines before={c.before} after={c.after} />
190
+ </View>
191
+ ))}
192
+ </View>
111
193
  {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>
194
+ <Text size="xs" color="muted" weight="medium">
195
+ {status === "applied" ? "Applied" : "Discarded"}
196
+ </Text>
122
197
  ) : (
123
198
  <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}
199
+ <View style={{ flex: 1 }} />
200
+ {props.onDiscard ? <Button title="Discard" color="muted" shape="rounded" onPress={props.onDiscard} /> : null}
132
201
  {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
- />
202
+ <Button title={props.applyLabel ?? "Apply"} color="primary" shape="rounded" onPress={props.onApply} />
140
203
  ) : null}
141
204
  </View>
142
205
  )}
@@ -144,23 +207,40 @@ export function ChangeReview(props: ChangeReviewProps) {
144
207
  );
145
208
  }
146
209
 
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
- });
210
+ const styles = {
211
+ ...StyleSheet.create({
212
+ card: {
213
+ borderWidth: 1,
214
+ borderColor: colors.border,
215
+ backgroundColor: colors.white,
216
+ borderRadius: 12,
217
+ padding: 16,
218
+ gap: 14,
219
+ },
220
+ resolved: { backgroundColor: colors.zinc[50] },
221
+ head: { flexDirection: "row", alignItems: "center", gap: 8 },
222
+ note: { borderLeftWidth: 2, borderLeftColor: colors.zinc[300], paddingLeft: 12 },
223
+ list: { gap: 8 },
224
+ block: { backgroundColor: colors.zinc[50], borderRadius: 10, padding: 14, gap: 10 },
225
+ cardActions: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 },
226
+ collapsed: {
227
+ flexDirection: "row",
228
+ alignItems: "center",
229
+ gap: 10,
230
+ backgroundColor: colors.zinc[50],
231
+ borderRadius: 10,
232
+ paddingLeft: 14,
233
+ paddingRight: 8,
234
+ paddingVertical: 6,
235
+ minHeight: 48,
236
+ },
237
+ statusTag: { flexDirection: "row", alignItems: "center", gap: 4 },
238
+ changeSpacer: { marginTop: 10, borderTopWidth: 1, borderTopColor: colors.zinc[200], paddingTop: 10 },
239
+ diff: { gap: 3 },
240
+ diffLine: { flexDirection: "row", alignItems: "baseline", gap: 8 },
241
+ strike: { textDecorationLine: "line-through" },
242
+ footer: { flexDirection: "row", alignItems: "center", gap: 8 },
243
+ }),
244
+ marker: (color: string) => ({ color, width: 12 }),
245
+ value: (color: string) => ({ color }),
246
+ };
@@ -0,0 +1,63 @@
1
+ import { StyleSheet, View } from "react-native";
2
+ import { colors } from "./colors";
3
+ import { Text } from "./text";
4
+ import { Icon } from "./icon";
5
+ import { CardSelectItem } from "./card_select_item";
6
+
7
+ export interface ChoiceOption {
8
+ label: string;
9
+ value: string;
10
+ /** An optional second line under the label. */
11
+ description?: string;
12
+ }
13
+
14
+ export interface ChoiceListProps {
15
+ options: ChoiceOption[];
16
+ /** The chosen value. */
17
+ value?: string;
18
+ onSelect: (value: string) => void;
19
+ }
20
+
21
+ /**
22
+ * A vertical set of selectable answer options — bordered cards with the global
23
+ * focus/hover/selected ring (`CardSelectItem`), single-select and freely
24
+ * switchable (pick a different option any time). The chosen card shows a check.
25
+ * The agent's quick-reply surface (`Clarify` uses it) and any "pick one"
26
+ * question.
27
+ */
28
+ export function ChoiceList(props: ChoiceListProps) {
29
+ const { options, value, onSelect } = props;
30
+ return (
31
+ <View style={{ gap: 8 }}>
32
+ {options.map((o) => {
33
+ const selected = value === o.value;
34
+ return (
35
+ <CardSelectItem
36
+ key={o.value}
37
+ selected={selected}
38
+ onPress={() => onSelect(o.value)}
39
+ accessibilityLabel={o.label}
40
+ style={styles.item}
41
+ >
42
+ <View style={styles.text}>
43
+ <Text size="sm" weight="medium">
44
+ {o.label}
45
+ </Text>
46
+ {o.description ? (
47
+ <Text size="xs" color="muted">
48
+ {o.description}
49
+ </Text>
50
+ ) : null}
51
+ </View>
52
+ {selected ? <Icon name="check" size={16} color={colors.zinc[900]} /> : null}
53
+ </CardSelectItem>
54
+ );
55
+ })}
56
+ </View>
57
+ );
58
+ }
59
+
60
+ const styles = StyleSheet.create({
61
+ item: { flexDirection: "row", alignItems: "center", gap: 10, paddingVertical: 11, paddingHorizontal: 14 },
62
+ text: { flex: 1, gap: 2 },
63
+ });
package/src/clarify.tsx CHANGED
@@ -1,54 +1,37 @@
1
1
  import { StyleSheet, View } from "react-native";
2
- import { colors, solid, tint } from "./colors";
2
+ import { colors } from "./colors";
3
3
  import { Text } from "./text";
4
- import { Icon } from "./icon";
5
- import { Button } from "./button";
4
+ import { ChoiceList, type ChoiceOption } from "./choice_list";
6
5
 
7
- export interface ClarifyOption {
8
- label: string;
9
- value: string;
10
- }
6
+ export type ClarifyOption = ChoiceOption;
11
7
 
12
8
  export interface ClarifyProps {
13
9
  /** The agent's question — what it needs settled to proceed. */
14
10
  question: string;
15
11
  options: ClarifyOption[];
16
12
  onAnswer: (value: string) => void;
17
- /** Once answered, the card settles showing the chosen reply (pass the option
18
- * label). */
13
+ /** The chosen option's VALUE the picked choice shows selected; it stays
14
+ * switchable so the human can change their mind. */
19
15
  answer?: string;
20
16
  }
21
17
 
22
18
  /**
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`.
19
+ * The agent asks BACK — a question with selectable answer options (`ChoiceList`)
20
+ * the human picks before the run continues, freely switchable until committed.
21
+ * Human-in-the-loop input: when the agent is unsure, it clarifies instead of
22
+ * guessing wrong. The run pauses on this and `onAnswer` resumes it. Pair with
23
+ * `AgentRun` / `AgentProgress`.
27
24
  */
28
25
  export function Clarify(props: ClarifyProps) {
29
- const answered = props.answer != null;
30
26
  return (
31
27
  <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
- )}
28
+ <Text size="xs" color="muted" weight="medium">
29
+ Question
30
+ </Text>
31
+ <Text size="sm" weight="medium">
32
+ {props.question}
33
+ </Text>
34
+ <ChoiceList options={props.options} value={props.answer} onSelect={props.onAnswer} />
52
35
  </View>
53
36
  );
54
37
  }
@@ -56,13 +39,10 @@ export function Clarify(props: ClarifyProps) {
56
39
  const styles = StyleSheet.create({
57
40
  card: {
58
41
  borderWidth: 1,
59
- borderColor: tint("violet", 0.35),
42
+ borderColor: colors.border,
60
43
  backgroundColor: colors.white,
61
44
  borderRadius: 12,
62
- padding: 14,
45
+ padding: 16,
63
46
  gap: 12,
64
47
  },
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
48
  });
@@ -1,5 +1,6 @@
1
- import { Badge } from "./badge";
2
- import type { ColorName } from "./colors";
1
+ import { StyleSheet, View } from "react-native";
2
+ import { colors, solid, type ColorName } from "./colors";
3
+ import { Text } from "./text";
3
4
 
4
5
  export type ConfidenceLevel = "high" | "medium" | "low";
5
6
 
@@ -10,8 +11,9 @@ export interface ConfidenceProps {
10
11
  }
11
12
 
12
13
  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.
14
+ const FILLED: Record<ConfidenceLevel, number> = { high: 3, medium: 2, low: 1 };
15
+ // high emerald, medium amber, low zinc (unsure not an error). One family per
16
+ // level; the fill count AND the colour both carry it.
15
17
  const COLOR: Record<ConfidenceLevel, ColorName> = { high: "emerald", medium: "amber", low: "zinc" };
16
18
 
17
19
  export function levelFromScore(score: number): ConfidenceLevel {
@@ -21,11 +23,31 @@ export function levelFromScore(score: number): ConfidenceLevel {
21
23
  }
22
24
 
23
25
  /**
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`.
26
+ * How sure the AI is — a calibrated high / medium / low read as a 3-tick meter
27
+ * (emerald / amber / zinc) beside the level word. The fill count and the colour
28
+ * both carry the level. The human weights an AI proposal or estimate by it. Pair
29
+ * with `Suggestion` and `RecordReview`.
27
30
  */
28
31
  export function Confidence(props: ConfidenceProps) {
29
32
  const level = props.level ?? (props.score != null ? levelFromScore(props.score) : "medium");
30
- return <Badge variant="dot" color={COLOR[level]} label={LABEL[level]} />;
33
+ const filled = FILLED[level];
34
+ const fill = solid(COLOR[level]);
35
+ return (
36
+ <View style={styles.row} accessibilityLabel={`${LABEL[level]} confidence`}>
37
+ <View style={styles.meter}>
38
+ {[0, 1, 2].map((i) => (
39
+ <View key={i} style={[styles.tick, { backgroundColor: i < filled ? fill : colors.zinc[200] }]} />
40
+ ))}
41
+ </View>
42
+ <Text size="xs" weight="medium" style={{ color: fill }}>
43
+ {LABEL[level]}
44
+ </Text>
45
+ </View>
46
+ );
31
47
  }
48
+
49
+ const styles = StyleSheet.create({
50
+ row: { flexDirection: "row", alignItems: "center", gap: 6 },
51
+ meter: { flexDirection: "row", alignItems: "center", gap: 2 },
52
+ tick: { width: 3, height: 10, borderRadius: 1 },
53
+ });