@lotics/ui 4.1.0 → 4.3.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 +97 -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 +14 -1
- 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/chip_group.tsx +16 -13
- package/src/clarify.tsx +68 -0
- package/src/colors.test.ts +45 -0
- package/src/colors.ts +25 -0
- package/src/confidence.tsx +31 -0
- package/src/file_dropzone.tsx +5 -3
- package/src/inline_edit.tsx +12 -5
- package/src/inline_member_select.tsx +62 -0
- package/src/inline_select.tsx +6 -2
- package/src/member_chip.tsx +51 -0
- package/src/member_select.tsx +81 -0
- package/src/option_badge.tsx +58 -0
- package/src/prompt_field.tsx +150 -0
- package/src/sources.tsx +92 -0
- package/src/suggestion.tsx +102 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, StyleSheet, StyleProp, ViewStyle } from "react-native";
|
|
3
|
+
import { Avatar } from "./avatar";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
|
|
6
|
+
interface MemberChipProps {
|
|
7
|
+
/** Display name. Empty/blank falls back to a neutral label — pass
|
|
8
|
+
* `name={member.name || member.email}` to prefer email when unnamed. */
|
|
9
|
+
name?: string | null;
|
|
10
|
+
/** Avatar image URL (a member's `image` from `useMembers`). Absent → initials. */
|
|
11
|
+
image?: string | null;
|
|
12
|
+
/** Optional secondary line under the name — e.g. email, role, department. */
|
|
13
|
+
secondary?: string | null;
|
|
14
|
+
/** Avatar diameter in px. Default 28. */
|
|
15
|
+
size?: number;
|
|
16
|
+
style?: StyleProp<ViewStyle>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Render a member/person as an avatar + name (+ optional secondary line) — the
|
|
21
|
+
* one canonical way to show a person inline: a member-picker option, an assignee,
|
|
22
|
+
* a `select_member` cell in a register or detail. The {@link Avatar} falls back
|
|
23
|
+
* to initials when there's no image.
|
|
24
|
+
*
|
|
25
|
+
* Pure: pass the member's fields in (from `useMembers`, or a resolved
|
|
26
|
+
* `select_member` cell joined against that roster); this component fetches
|
|
27
|
+
* nothing and carries no domain types.
|
|
28
|
+
*/
|
|
29
|
+
export function MemberChip({ name, image, secondary, size = 28, style }: MemberChipProps) {
|
|
30
|
+
const displayName = name?.trim() || "Unknown";
|
|
31
|
+
return (
|
|
32
|
+
<View style={[styles.row, style]}>
|
|
33
|
+
<Avatar size={size} name={displayName} source={image ? { uri: image } : undefined} />
|
|
34
|
+
<View style={styles.text}>
|
|
35
|
+
<Text userSelect="none" numberOfLines={1}>
|
|
36
|
+
{displayName}
|
|
37
|
+
</Text>
|
|
38
|
+
{secondary ? (
|
|
39
|
+
<Text userSelect="none" size="sm" color="zinc-500" numberOfLines={1}>
|
|
40
|
+
{secondary}
|
|
41
|
+
</Text>
|
|
42
|
+
) : null}
|
|
43
|
+
</View>
|
|
44
|
+
</View>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const styles = StyleSheet.create({
|
|
49
|
+
row: { flexDirection: "row", alignItems: "center", gap: 6 },
|
|
50
|
+
text: { flexShrink: 1 },
|
|
51
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import React, { useCallback, useMemo } from "react";
|
|
2
|
+
import { StyleProp, ViewStyle } from "react-native";
|
|
3
|
+
import {
|
|
4
|
+
Picker,
|
|
5
|
+
type PickerOption,
|
|
6
|
+
type PickerValue,
|
|
7
|
+
type PickerOnValueChange,
|
|
8
|
+
type PickerOnClose,
|
|
9
|
+
} from "./picker";
|
|
10
|
+
import { MemberChip } from "./member_chip";
|
|
11
|
+
|
|
12
|
+
/** A candidate member for {@link MemberSelect}. Shaped to accept a `useMembers`
|
|
13
|
+
* row from `@lotics/app-sdk` (or any roster) directly. */
|
|
14
|
+
export interface MemberSelectMember {
|
|
15
|
+
id: string;
|
|
16
|
+
name?: string | null;
|
|
17
|
+
image?: string | null;
|
|
18
|
+
email?: string | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface MemberSelectProps<MULTI extends boolean = false> {
|
|
22
|
+
/**
|
|
23
|
+
* The candidate roster. An app feeds `useMembers()`; the product feeds any
|
|
24
|
+
* directory. Each member carries its own avatar (`image`), so options render
|
|
25
|
+
* with no side lookup.
|
|
26
|
+
*/
|
|
27
|
+
members: MemberSelectMember[];
|
|
28
|
+
value?: PickerValue<string, MULTI> | null;
|
|
29
|
+
onValueChange?: PickerOnValueChange<string, MULTI>;
|
|
30
|
+
onClose?: PickerOnClose<string, MULTI>;
|
|
31
|
+
multi?: MULTI;
|
|
32
|
+
placeholder?: string;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
autoFocus?: boolean;
|
|
35
|
+
includeEmptyOption?: boolean;
|
|
36
|
+
style?: StyleProp<ViewStyle>;
|
|
37
|
+
testID?: string;
|
|
38
|
+
accessibilityLabel?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Choose member(s) — a {@link Picker} whose every option renders as a
|
|
43
|
+
* {@link MemberChip} (avatar + name). The one member-picker shape, so an app
|
|
44
|
+
* never re-wires `Picker` + `renderOptionContent` + a directory by hand. PURE:
|
|
45
|
+
* pass the candidate `members` (an app feeds `useMembers`, the product any
|
|
46
|
+
* roster); this fetches nothing. Single or multi via `multi`. Options are
|
|
47
|
+
* single-line by design — a picker row identifies a person by name + avatar;
|
|
48
|
+
* for a denser identity (email/role), render {@link MemberChip} with `secondary`
|
|
49
|
+
* on a roomier surface (a register row, a detail), not in a picker.
|
|
50
|
+
*
|
|
51
|
+
* ```tsx
|
|
52
|
+
* const { members } = useMembers();
|
|
53
|
+
* <MemberSelect members={members} value={assignee} onValueChange={setAssignee} />
|
|
54
|
+
* ```
|
|
55
|
+
*/
|
|
56
|
+
export function MemberSelect<MULTI extends boolean = false>(props: MemberSelectProps<MULTI>) {
|
|
57
|
+
const { members, ...picker } = props;
|
|
58
|
+
|
|
59
|
+
const byId = useMemo(() => new Map(members.map((m) => [m.id, m])), [members]);
|
|
60
|
+
const options = useMemo<PickerOption<string>[]>(
|
|
61
|
+
() => members.map((m) => ({ value: m.id, label: m.name || m.email || m.id })),
|
|
62
|
+
[members],
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
const renderOptionContent = useCallback(
|
|
66
|
+
(option: PickerOption<string>) => {
|
|
67
|
+
const member = byId.get(option.value);
|
|
68
|
+
if (!member) return null;
|
|
69
|
+
return <MemberChip name={member.name || member.email} image={member.image} />;
|
|
70
|
+
},
|
|
71
|
+
[byId],
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<Picker<string, MULTI>
|
|
76
|
+
options={options}
|
|
77
|
+
renderOptionContent={renderOptionContent}
|
|
78
|
+
{...picker}
|
|
79
|
+
/>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { View, StyleSheet, StyleProp, ViewStyle } from "react-native";
|
|
3
|
+
import { Badge } from "./badge";
|
|
4
|
+
import { asColorName } from "./colors";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One select option, as carried by a query CELL (`readSelect`) or the option
|
|
8
|
+
* LIST (`useFieldOptions`) in `@lotics/app-sdk`. `key` is optional — used only
|
|
9
|
+
* as a stable React key. `color` is a palette token; it may be absent (a cell
|
|
10
|
+
* carries only key + label) or a token this UI build doesn't recognize — either
|
|
11
|
+
* way the badge degrades to a neutral color.
|
|
12
|
+
*/
|
|
13
|
+
export interface OptionValue {
|
|
14
|
+
key?: string;
|
|
15
|
+
label: string;
|
|
16
|
+
color?: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface OptionBadgeProps {
|
|
20
|
+
/**
|
|
21
|
+
* A single option, an array (a multi-select cell → one badge each), or
|
|
22
|
+
* null/empty (renders nothing — pair with your own placeholder).
|
|
23
|
+
*/
|
|
24
|
+
value?: OptionValue | OptionValue[] | null;
|
|
25
|
+
/** Badge weight — see {@link Badge}. Default "tonal". */
|
|
26
|
+
variant?: "tonal" | "dot";
|
|
27
|
+
style?: StyleProp<ViewStyle>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Render a select-field value as colored {@link Badge}(s) with the option's
|
|
32
|
+
* CONFIGURED color resolved automatically — so an app never hand-maintains an
|
|
33
|
+
* option-key → color map, and a freshly added or renamed option just renders.
|
|
34
|
+
* Multi-select wraps to one badge per option; an empty value renders nothing; an
|
|
35
|
+
* option with a missing/unrecognized color token falls back to a neutral badge.
|
|
36
|
+
*
|
|
37
|
+
* Feed it straight from `@lotics/app-sdk`: a `useFieldOptions` option for a
|
|
38
|
+
* picker, or `byKey(readSelect(cell)[0]?.key)` for a stored value.
|
|
39
|
+
*/
|
|
40
|
+
export function OptionBadge({ value, variant, style }: OptionBadgeProps) {
|
|
41
|
+
const options = value == null ? [] : Array.isArray(value) ? value : [value];
|
|
42
|
+
if (options.length === 0) return null;
|
|
43
|
+
if (options.length === 1) {
|
|
44
|
+
const o = options[0];
|
|
45
|
+
return <Badge label={o.label} color={asColorName(o.color)} variant={variant} style={style} />;
|
|
46
|
+
}
|
|
47
|
+
return (
|
|
48
|
+
<View style={[styles.wrap, style]}>
|
|
49
|
+
{options.map((o, i) => (
|
|
50
|
+
<Badge key={o.key ?? String(i)} label={o.label} color={asColorName(o.color)} variant={variant} />
|
|
51
|
+
))}
|
|
52
|
+
</View>
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const styles = StyleSheet.create({
|
|
57
|
+
wrap: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 4 },
|
|
58
|
+
});
|
|
@@ -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
|
+
});
|
package/src/sources.tsx
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { colors } from "./colors";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Icon, type IconName } from "./icon";
|
|
5
|
+
import { PressableHighlight } from "./pressable_highlight";
|
|
6
|
+
|
|
7
|
+
export type SourceKind = "record" | "document" | "table" | "web" | "knowledge";
|
|
8
|
+
|
|
9
|
+
export interface SourceRef {
|
|
10
|
+
id: string;
|
|
11
|
+
label: string;
|
|
12
|
+
kind?: SourceKind;
|
|
13
|
+
/** A locator under the label — "page 2", a record code, a column. */
|
|
14
|
+
detail?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface SourcesProps {
|
|
18
|
+
sources: SourceRef[];
|
|
19
|
+
/** Eyebrow above the chips. Default "Sources". */
|
|
20
|
+
label?: string;
|
|
21
|
+
/** Pass to make each chip openable (jump to the record / document / page). */
|
|
22
|
+
onOpen?: (source: SourceRef) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const KIND_ICON: Record<SourceKind, IconName> = {
|
|
26
|
+
record: "box",
|
|
27
|
+
document: "file-text",
|
|
28
|
+
table: "table-2",
|
|
29
|
+
web: "external-link",
|
|
30
|
+
knowledge: "book-open",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Where the AI's output came FROM — a row of source chips under a generated
|
|
35
|
+
* answer, summary, or extracted value, each openable. Provenance is what makes
|
|
36
|
+
* AI output verifiable; show it on anything the agent produced from data it
|
|
37
|
+
* read (extraction, summarization, Q&A). Renders nothing for an empty list.
|
|
38
|
+
*/
|
|
39
|
+
export function Sources(props: SourcesProps) {
|
|
40
|
+
if (props.sources.length === 0) return null;
|
|
41
|
+
return (
|
|
42
|
+
<View style={{ gap: 6 }}>
|
|
43
|
+
<Text size="xs" color="muted" transform="uppercase">
|
|
44
|
+
{props.label ?? "Sources"}
|
|
45
|
+
</Text>
|
|
46
|
+
<View style={styles.row}>
|
|
47
|
+
{props.sources.map((s) =>
|
|
48
|
+
props.onOpen ? (
|
|
49
|
+
<PressableHighlight key={s.id} onPress={() => props.onOpen?.(s)} style={styles.chip}>
|
|
50
|
+
<SourceChip source={s} />
|
|
51
|
+
</PressableHighlight>
|
|
52
|
+
) : (
|
|
53
|
+
<View key={s.id} style={styles.chip}>
|
|
54
|
+
<SourceChip source={s} />
|
|
55
|
+
</View>
|
|
56
|
+
),
|
|
57
|
+
)}
|
|
58
|
+
</View>
|
|
59
|
+
</View>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function SourceChip({ source }: { source: SourceRef }) {
|
|
64
|
+
return (
|
|
65
|
+
<>
|
|
66
|
+
<Icon name={KIND_ICON[source.kind ?? "document"]} size={12} color={colors.zinc[500]} />
|
|
67
|
+
<Text size="xs" weight="medium" numberOfLines={1}>
|
|
68
|
+
{source.label}
|
|
69
|
+
</Text>
|
|
70
|
+
{source.detail ? (
|
|
71
|
+
<Text size="xs" color="muted" numberOfLines={1}>
|
|
72
|
+
{source.detail}
|
|
73
|
+
</Text>
|
|
74
|
+
) : null}
|
|
75
|
+
</>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const styles = StyleSheet.create({
|
|
80
|
+
row: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
|
|
81
|
+
chip: {
|
|
82
|
+
flexDirection: "row",
|
|
83
|
+
alignItems: "center",
|
|
84
|
+
gap: 6,
|
|
85
|
+
paddingHorizontal: 10,
|
|
86
|
+
paddingVertical: 5,
|
|
87
|
+
borderRadius: 8,
|
|
88
|
+
borderWidth: 1,
|
|
89
|
+
borderColor: colors.border,
|
|
90
|
+
backgroundColor: colors.white,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { ReactNode } from "react";
|
|
2
|
+
import { StyleSheet, View } from "react-native";
|
|
3
|
+
import { colors, solid, tint } from "./colors";
|
|
4
|
+
import { Text } from "./text";
|
|
5
|
+
import { Icon } from "./icon";
|
|
6
|
+
import { Button } from "./button";
|
|
7
|
+
import { LinkButton } from "./link_button";
|
|
8
|
+
import { Confidence, type ConfidenceLevel } from "./confidence";
|
|
9
|
+
|
|
10
|
+
export interface SuggestionProps {
|
|
11
|
+
/** The proposal headline — what the AI suggests. */
|
|
12
|
+
title: string;
|
|
13
|
+
/** One line of WHY — the rationale the human is weighing. */
|
|
14
|
+
rationale?: string;
|
|
15
|
+
confidence?: ConfidenceLevel;
|
|
16
|
+
confidenceScore?: number;
|
|
17
|
+
/** The proposal body — render the proposed value/preview however fits (a
|
|
18
|
+
* field stack, a chip row, a diagram). Omit for a title-only suggestion. */
|
|
19
|
+
children?: ReactNode;
|
|
20
|
+
onAccept?: () => void;
|
|
21
|
+
/** Tweak before accepting — the host opens its editor. */
|
|
22
|
+
onEdit?: () => void;
|
|
23
|
+
onDismiss?: () => void;
|
|
24
|
+
acceptLabel?: string;
|
|
25
|
+
/** Once resolved the card settles to a quiet outcome line (no actions). */
|
|
26
|
+
status?: "open" | "accepted" | "dismissed";
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The core review-for-approval surface: an AI proposal the human accepts,
|
|
31
|
+
* edits, or dismisses — never auto-applied. The violet sparkle marks it as
|
|
32
|
+
* AI-proposed; a `Confidence` chip says how sure; the body shows the proposed
|
|
33
|
+
* value. The decision is the human's. For a shortlist, stack several (rank by
|
|
34
|
+
* confidence, the top one first). Pair with `AgentRun` (what produced it) and
|
|
35
|
+
* `ChangeReview` (when the proposal is an edit to existing state).
|
|
36
|
+
*/
|
|
37
|
+
export function Suggestion(props: SuggestionProps) {
|
|
38
|
+
const status = props.status ?? "open";
|
|
39
|
+
const hasConfidence = props.confidence != null || props.confidenceScore != null;
|
|
40
|
+
return (
|
|
41
|
+
<View style={[styles.card, status !== "open" ? styles.resolved : null]}>
|
|
42
|
+
<View style={styles.header}>
|
|
43
|
+
<Icon name="sparkles" size={14} color={solid("violet")} />
|
|
44
|
+
<Text size="sm" weight="semibold" style={{ flex: 1 }} numberOfLines={2}>
|
|
45
|
+
{props.title}
|
|
46
|
+
</Text>
|
|
47
|
+
{status === "open" && hasConfidence ? (
|
|
48
|
+
<Confidence level={props.confidence} score={props.confidenceScore} />
|
|
49
|
+
) : null}
|
|
50
|
+
</View>
|
|
51
|
+
|
|
52
|
+
{props.rationale ? (
|
|
53
|
+
<Text size="xs" color="muted">
|
|
54
|
+
{props.rationale}
|
|
55
|
+
</Text>
|
|
56
|
+
) : null}
|
|
57
|
+
|
|
58
|
+
{props.children ? <View>{props.children}</View> : null}
|
|
59
|
+
|
|
60
|
+
{status !== "open" ? (
|
|
61
|
+
<View style={styles.outcome}>
|
|
62
|
+
<Icon
|
|
63
|
+
name={status === "accepted" ? "circle-check" : "x"}
|
|
64
|
+
size={14}
|
|
65
|
+
color={status === "accepted" ? solid("emerald") : colors.zinc[400]}
|
|
66
|
+
/>
|
|
67
|
+
<Text size="xs" color="muted">
|
|
68
|
+
{status === "accepted" ? "Accepted" : "Dismissed"}
|
|
69
|
+
</Text>
|
|
70
|
+
</View>
|
|
71
|
+
) : (
|
|
72
|
+
<View style={styles.footer}>
|
|
73
|
+
{props.onDismiss ? <LinkButton title="Dismiss" onPress={props.onDismiss} /> : <View />}
|
|
74
|
+
<View style={styles.actions}>
|
|
75
|
+
{props.onEdit ? (
|
|
76
|
+
<Button title="Edit" color="secondary" shape="rounded" icon="square-pen" onPress={props.onEdit} />
|
|
77
|
+
) : null}
|
|
78
|
+
{props.onAccept ? (
|
|
79
|
+
<Button title={props.acceptLabel ?? "Accept"} color="primary" shape="rounded" onPress={props.onAccept} />
|
|
80
|
+
) : null}
|
|
81
|
+
</View>
|
|
82
|
+
</View>
|
|
83
|
+
)}
|
|
84
|
+
</View>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const styles = StyleSheet.create({
|
|
89
|
+
card: {
|
|
90
|
+
borderWidth: 1,
|
|
91
|
+
borderColor: tint("violet", 0.35),
|
|
92
|
+
backgroundColor: colors.white,
|
|
93
|
+
borderRadius: 12,
|
|
94
|
+
padding: 14,
|
|
95
|
+
gap: 10,
|
|
96
|
+
},
|
|
97
|
+
resolved: { borderColor: colors.border, backgroundColor: colors.zinc[50] },
|
|
98
|
+
header: { flexDirection: "row", alignItems: "center", gap: 8 },
|
|
99
|
+
outcome: { flexDirection: "row", alignItems: "center", gap: 6 },
|
|
100
|
+
footer: { flexDirection: "row", alignItems: "center", justifyContent: "space-between" },
|
|
101
|
+
actions: { flexDirection: "row", alignItems: "center", gap: 8 },
|
|
102
|
+
});
|