@lotics/ui 4.4.0 → 4.6.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 +108 -31
- package/examples/tpl_assistant.tsx +6 -6
- package/examples/tpl_briefing.tsx +121 -0
- package/examples/tpl_compare.tsx +133 -0
- package/examples/tpl_crosscheck.tsx +120 -0
- package/examples/tpl_dieline.tsx +218 -55
- package/examples/tpl_draft.tsx +7 -9
- package/examples/tpl_extract.tsx +91 -92
- package/examples/tpl_lookup.tsx +288 -0
- package/examples/tpl_match.tsx +123 -0
- package/examples/tpl_triage.tsx +112 -0
- package/package.json +9 -2
- package/src/agent_progress.tsx +35 -23
- package/src/agent_run.tsx +60 -80
- package/src/change_review.tsx +190 -110
- package/src/choice_list.tsx +63 -0
- package/src/clarify.tsx +19 -39
- package/src/confidence.tsx +30 -8
- package/src/date_filter.tsx +3 -11
- package/src/date_range_filter_field.tsx +38 -27
- package/src/discrepancy.tsx +114 -0
- package/src/finding.tsx +104 -0
- package/src/format_date.test.ts +32 -4
- package/src/format_date.ts +61 -23
- package/src/inline_date_picker.tsx +3 -1
- package/src/match_row.tsx +133 -0
- package/src/prompt_field.tsx +47 -89
- package/src/record_review.tsx +149 -0
- package/src/scored_option.tsx +139 -0
- package/src/sources.tsx +38 -21
- package/src/spec_list.tsx +81 -0
- package/src/suggestion.tsx +35 -45
- package/src/time_field.tsx +4 -5
- package/src/triage_row.tsx +99 -0
- package/src/assisted_field.tsx +0 -93
package/src/prompt_field.tsx
CHANGED
|
@@ -6,38 +6,38 @@ import {
|
|
|
6
6
|
TextInputKeyPressEventData,
|
|
7
7
|
View,
|
|
8
8
|
} from "react-native";
|
|
9
|
-
import { colors
|
|
10
|
-
import {
|
|
11
|
-
import { IconButton } from "./icon_button";
|
|
12
|
-
import { ActivityIndicator } from "./activity_indicator";
|
|
13
|
-
import { ACTIVE_RING } from "./control_surface";
|
|
9
|
+
import { colors } from "./colors";
|
|
10
|
+
import { Button } from "./button";
|
|
14
11
|
import { getInputTextStyle, fontFamilyRegular } from "./text_utils";
|
|
15
12
|
|
|
16
13
|
export interface PromptFieldProps {
|
|
17
14
|
value: string;
|
|
18
15
|
onChangeText: (value: string) => void;
|
|
19
|
-
/** Fired on Enter
|
|
20
|
-
*
|
|
16
|
+
/** Fired on Enter or the send button. Receives the trimmed text; never fires
|
|
17
|
+
* empty or while busy. */
|
|
21
18
|
onSubmit: (value: string) => void;
|
|
22
19
|
placeholder?: string;
|
|
23
|
-
/** The agent is working — the field locks
|
|
24
|
-
*
|
|
25
|
-
*
|
|
20
|
+
/** The agent is working — the field locks. Typically the host swaps the field
|
|
21
|
+
* for `AgentProgress` while busy (the composer MORPHS into the progress
|
|
22
|
+
* pill); the two share the same pill geometry so the swap reads as a morph. */
|
|
26
23
|
busy?: boolean;
|
|
27
24
|
disabled?: boolean;
|
|
28
25
|
autoFocus?: boolean;
|
|
29
26
|
accessibilityLabel?: string;
|
|
30
|
-
/** When set, a
|
|
31
|
-
* attach files (e.g. the photo that starts a run).
|
|
27
|
+
/** When set, a circular attach button sits at the left of the row — the
|
|
28
|
+
* composer can attach files (e.g. the photo that starts a run). */
|
|
32
29
|
onAttach?: () => void;
|
|
33
30
|
}
|
|
34
31
|
|
|
32
|
+
const ROW = 40;
|
|
33
|
+
|
|
35
34
|
/**
|
|
36
|
-
* The natural-language command surface for an AI app — a
|
|
37
|
-
* triggers agent work, NOT a chat composer.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
35
|
+
* The natural-language command surface for an AI app — a compact single-row
|
|
36
|
+
* composer pill that triggers agent work, NOT a chat composer. A circular attach
|
|
37
|
+
* `Button` on the left, the input filling the row at the same height as the
|
|
38
|
+
* buttons, a circular send-arrow `Button` on the right; Enter sends. While the
|
|
39
|
+
* agent runs the host swaps this for `AgentProgress` — same pill geometry, so it
|
|
40
|
+
* morphs into the progress indicator.
|
|
41
41
|
*/
|
|
42
42
|
export function PromptField(props: PromptFieldProps) {
|
|
43
43
|
const { value, onChangeText, onSubmit, placeholder, busy, disabled, autoFocus, accessibilityLabel, onAttach } = props;
|
|
@@ -50,8 +50,6 @@ export function PromptField(props: PromptFieldProps) {
|
|
|
50
50
|
onSubmit(value.trim());
|
|
51
51
|
}, [value, busy, disabled, onSubmit]);
|
|
52
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
53
|
const onKeyPress = useCallback(
|
|
56
54
|
(e: NativeSyntheticEvent<TextInputKeyPressEventData>) => {
|
|
57
55
|
const ne = e.nativeEvent as unknown as { key: string; shiftKey?: boolean };
|
|
@@ -64,87 +62,47 @@ export function PromptField(props: PromptFieldProps) {
|
|
|
64
62
|
);
|
|
65
63
|
|
|
66
64
|
return (
|
|
67
|
-
<View
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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>
|
|
65
|
+
<View style={[styles.box, focused ? styles.boxFocused : null, disabled ? styles.boxDisabled : null]}>
|
|
66
|
+
{onAttach ? (
|
|
67
|
+
<Button icon="paperclip" color="secondary" accessibilityLabel="Attach files" onPress={onAttach} disabled={busy || disabled} />
|
|
68
|
+
) : null}
|
|
69
|
+
<RNTextInput
|
|
70
|
+
accessibilityLabel={accessibilityLabel ?? placeholder}
|
|
71
|
+
style={[styles.input, getInputTextStyle(), { outline: "none", outlineStyle: "none", outlineWidth: 0 } as object]}
|
|
72
|
+
value={value}
|
|
73
|
+
onChangeText={onChangeText}
|
|
74
|
+
onKeyPress={onKeyPress}
|
|
75
|
+
onSubmitEditing={submit}
|
|
76
|
+
onFocus={() => setFocused(true)}
|
|
77
|
+
onBlur={() => setFocused(false)}
|
|
78
|
+
placeholder={placeholder}
|
|
79
|
+
placeholderTextColor={colors.zinc[400]}
|
|
80
|
+
editable={!busy && !disabled}
|
|
81
|
+
autoFocus={autoFocus}
|
|
82
|
+
/>
|
|
83
|
+
<Button icon="arrow-up" color="primary" accessibilityLabel="Send" onPress={submit} disabled={!canSubmit} />
|
|
113
84
|
</View>
|
|
114
85
|
);
|
|
115
86
|
}
|
|
116
87
|
|
|
117
88
|
const styles = StyleSheet.create({
|
|
118
89
|
box: {
|
|
119
|
-
|
|
90
|
+
flexDirection: "row",
|
|
91
|
+
alignItems: "center",
|
|
92
|
+
gap: 6,
|
|
93
|
+
minHeight: 52,
|
|
94
|
+
borderWidth: 1.5,
|
|
120
95
|
borderColor: colors.border,
|
|
121
|
-
borderRadius:
|
|
96
|
+
borderRadius: 999,
|
|
122
97
|
backgroundColor: colors.white,
|
|
123
|
-
paddingHorizontal:
|
|
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,
|
|
98
|
+
paddingHorizontal: 6,
|
|
137
99
|
},
|
|
100
|
+
boxFocused: { borderColor: colors.zinc[900] },
|
|
101
|
+
boxDisabled: { backgroundColor: colors.zinc[50] },
|
|
138
102
|
input: {
|
|
139
103
|
flex: 1,
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
paddingTop: 4,
|
|
143
|
-
paddingBottom: 0,
|
|
104
|
+
height: ROW,
|
|
105
|
+
paddingHorizontal: 10,
|
|
144
106
|
fontFamily: fontFamilyRegular,
|
|
145
|
-
// RN-Web focus outline is handled by the box ring, not the raw input.
|
|
146
|
-
outlineStyle: "none",
|
|
147
107
|
} as object,
|
|
148
|
-
footer: { flexDirection: "row", alignItems: "center" },
|
|
149
|
-
sendSlot: { width: 28, height: 28, alignItems: "center", justifyContent: "center" },
|
|
150
108
|
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { colors, solid } from "./colors";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Icon } from "./icon";
|
|
5
|
+
import { Button } from "./button";
|
|
6
|
+
import { InlineTextInput } from "./inline_text_input";
|
|
7
|
+
import { Confidence, type ConfidenceLevel } from "./confidence";
|
|
8
|
+
|
|
9
|
+
export interface RecordField {
|
|
10
|
+
label: string;
|
|
11
|
+
value: string;
|
|
12
|
+
/** Unit suffix shown after the value (₫, kg, pcs). */
|
|
13
|
+
unit?: string;
|
|
14
|
+
/** Mark a low-confidence field worth double-checking — a subtle amber dot. */
|
|
15
|
+
uncertain?: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RecordReviewProps {
|
|
19
|
+
/** A short identifier for the record ("Carton 1", a code, a name). */
|
|
20
|
+
title?: string;
|
|
21
|
+
fields: RecordField[];
|
|
22
|
+
confidence?: ConfidenceLevel;
|
|
23
|
+
confidenceScore?: number;
|
|
24
|
+
/** Edit a field — fired on save of the inline editor (blur / Enter). When set,
|
|
25
|
+
* each value is click-to-edit; omit for a read-only review. */
|
|
26
|
+
onEditField?: (index: number, value: string) => void;
|
|
27
|
+
/** Confirm the record — it collapses to a summary the host can re-open. */
|
|
28
|
+
onConfirm?: () => void;
|
|
29
|
+
/** Remove / skip the record (don't add it). */
|
|
30
|
+
onRemove?: () => void;
|
|
31
|
+
/** Re-open a confirmed or removed record. */
|
|
32
|
+
onUndo?: () => void;
|
|
33
|
+
status?: "pending" | "confirmed" | "removed";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* One extracted record under review before it's saved — the agent's fields, each
|
|
38
|
+
* click-to-edit (`InlineTextInput`), with a confidence and Confirm / Remove. On
|
|
39
|
+
* confirm (or remove) the card COLLAPSES to a one-line summary with Undo, so a
|
|
40
|
+
* long extraction reads as a tidy checklist of what's been reviewed. Stack
|
|
41
|
+
* several under a "Save all" to capture a whole document → table; the source +
|
|
42
|
+
* streaming + save belong to the host (see `tpl_extract`). Replaces per-field
|
|
43
|
+
* confirmable estimates with a whole-record review.
|
|
44
|
+
*/
|
|
45
|
+
export function RecordReview(props: RecordReviewProps) {
|
|
46
|
+
const status = props.status ?? "pending";
|
|
47
|
+
const hasConfidence = props.confidence != null || props.confidenceScore != null;
|
|
48
|
+
|
|
49
|
+
if (status === "confirmed" || status === "removed") {
|
|
50
|
+
const removed = status === "removed";
|
|
51
|
+
return (
|
|
52
|
+
<View style={styles.collapsed}>
|
|
53
|
+
<Icon name={removed ? "x" : "check"} size={15} color={removed ? colors.zinc[400] : solid("emerald")} />
|
|
54
|
+
<Text
|
|
55
|
+
size="sm"
|
|
56
|
+
weight="medium"
|
|
57
|
+
color={removed ? "muted" : "default"}
|
|
58
|
+
numberOfLines={1}
|
|
59
|
+
style={[{ flex: 1 }, removed ? styles.strike : null]}
|
|
60
|
+
>
|
|
61
|
+
{props.title ?? props.fields[0]?.value ?? "Record"}
|
|
62
|
+
</Text>
|
|
63
|
+
<Text size="sm" weight="medium" color="muted">
|
|
64
|
+
{removed ? "Removed" : "Confirmed"}
|
|
65
|
+
</Text>
|
|
66
|
+
{props.onUndo ? <Button title="Undo" color="muted" shape="rounded" onPress={props.onUndo} /> : null}
|
|
67
|
+
</View>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<View style={styles.card}>
|
|
73
|
+
<View style={styles.header}>
|
|
74
|
+
<Text size="sm" weight="semibold" style={{ flex: 1 }} numberOfLines={1}>
|
|
75
|
+
{props.title ?? "Record"}
|
|
76
|
+
</Text>
|
|
77
|
+
{hasConfidence ? <Confidence level={props.confidence} score={props.confidenceScore} /> : null}
|
|
78
|
+
</View>
|
|
79
|
+
|
|
80
|
+
<View style={styles.fields}>
|
|
81
|
+
{props.fields.map((f, i) => (
|
|
82
|
+
<View key={`${f.label}-${i}`} style={styles.fieldRow}>
|
|
83
|
+
<View style={styles.labelCol}>
|
|
84
|
+
{f.uncertain ? <View style={styles.uncertainDot} /> : null}
|
|
85
|
+
<Text size="sm" color="muted" numberOfLines={1}>
|
|
86
|
+
{f.label}
|
|
87
|
+
</Text>
|
|
88
|
+
</View>
|
|
89
|
+
<View style={styles.valueCol}>
|
|
90
|
+
<View style={{ flex: 1 }}>
|
|
91
|
+
{props.onEditField ? (
|
|
92
|
+
<InlineTextInput value={f.value} accessibilityLabel={`Edit ${f.label}`} onSave={(next) => props.onEditField?.(i, next)} />
|
|
93
|
+
) : (
|
|
94
|
+
<Text size="sm" weight="medium" tabular>
|
|
95
|
+
{f.value}
|
|
96
|
+
</Text>
|
|
97
|
+
)}
|
|
98
|
+
</View>
|
|
99
|
+
{f.unit ? (
|
|
100
|
+
<Text size="sm" color="muted">
|
|
101
|
+
{f.unit}
|
|
102
|
+
</Text>
|
|
103
|
+
) : null}
|
|
104
|
+
</View>
|
|
105
|
+
</View>
|
|
106
|
+
))}
|
|
107
|
+
</View>
|
|
108
|
+
|
|
109
|
+
{props.onConfirm || props.onRemove ? (
|
|
110
|
+
<View style={styles.footer}>
|
|
111
|
+
{props.onRemove ? <Button title="Remove" color="muted" shape="rounded" onPress={props.onRemove} /> : null}
|
|
112
|
+
{props.onConfirm ? <Button title="Confirm" color="secondary" shape="rounded" onPress={props.onConfirm} /> : null}
|
|
113
|
+
</View>
|
|
114
|
+
) : null}
|
|
115
|
+
</View>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const styles = StyleSheet.create({
|
|
120
|
+
card: {
|
|
121
|
+
borderWidth: 1,
|
|
122
|
+
borderColor: colors.border,
|
|
123
|
+
backgroundColor: colors.white,
|
|
124
|
+
borderRadius: 12,
|
|
125
|
+
padding: 16,
|
|
126
|
+
gap: 12,
|
|
127
|
+
},
|
|
128
|
+
header: { flexDirection: "row", alignItems: "center", gap: 10 },
|
|
129
|
+
fields: { gap: 2 },
|
|
130
|
+
fieldRow: { flexDirection: "row", alignItems: "center", gap: 16, minHeight: 34 },
|
|
131
|
+
labelCol: { flexDirection: "row", alignItems: "center", gap: 6, width: 132 },
|
|
132
|
+
uncertainDot: { width: 6, height: 6, borderRadius: 999, backgroundColor: solid("amber") },
|
|
133
|
+
valueCol: { flex: 1, flexDirection: "row", alignItems: "center", gap: 6 },
|
|
134
|
+
collapsed: {
|
|
135
|
+
flexDirection: "row",
|
|
136
|
+
alignItems: "center",
|
|
137
|
+
gap: 10,
|
|
138
|
+
backgroundColor: colors.white,
|
|
139
|
+
borderWidth: 1,
|
|
140
|
+
borderColor: colors.border,
|
|
141
|
+
borderRadius: 12,
|
|
142
|
+
paddingLeft: 14,
|
|
143
|
+
paddingRight: 8,
|
|
144
|
+
paddingVertical: 6,
|
|
145
|
+
minHeight: 48,
|
|
146
|
+
},
|
|
147
|
+
strike: { textDecorationLine: "line-through" },
|
|
148
|
+
footer: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 },
|
|
149
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import { StyleSheet, View, type ViewStyle } from "react-native";
|
|
2
|
+
import { colors } from "./colors";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Button } from "./button";
|
|
5
|
+
import { SpecList, type SpecRow } from "./spec_list";
|
|
6
|
+
|
|
7
|
+
export interface ScoredOptionProps {
|
|
8
|
+
/** Rank in the shortlist (1 = top). */
|
|
9
|
+
rank?: number;
|
|
10
|
+
title: string;
|
|
11
|
+
/** Secondary line — the provider, the route summary. */
|
|
12
|
+
subtitle?: string;
|
|
13
|
+
/** 0–1 score the agent assigned — drives the score bar + figure. */
|
|
14
|
+
score?: number;
|
|
15
|
+
/** One line on why it ranks where it does. */
|
|
16
|
+
rationale?: string;
|
|
17
|
+
/** Key specs (price, transit, …) — rendered as a dense `SpecList`. */
|
|
18
|
+
specs?: SpecRow[];
|
|
19
|
+
/** The agent's top pick — a "RECOMMENDED" label + a heavier border. */
|
|
20
|
+
recommended?: boolean;
|
|
21
|
+
selected?: boolean;
|
|
22
|
+
onSelect?: () => void;
|
|
23
|
+
selectLabel?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* An AI-RANKED candidate in a shortlist — rank, score, the agent's one-line
|
|
28
|
+
* reason (a margin note), and the key specs — that the human picks from. The
|
|
29
|
+
* agent scores and orders; the choice stays the human's. Mark the agent's top
|
|
30
|
+
* pick `recommended` (a label + heavier border, no colour). The unit of an AI
|
|
31
|
+
* compare / shortlist / recommend surface (quotes, carriers, suppliers, plans).
|
|
32
|
+
* Monochrome and iconless. Stack several, highest rank first.
|
|
33
|
+
*/
|
|
34
|
+
export function ScoredOption(props: ScoredOptionProps) {
|
|
35
|
+
const { rank, title, subtitle, score, rationale, specs, recommended, selected, onSelect, selectLabel } = props;
|
|
36
|
+
const pct = score != null ? Math.round(score * 100) : null;
|
|
37
|
+
return (
|
|
38
|
+
<View style={[styles.card, recommended ? styles.recCard : null, selected ? styles.selected : null]}>
|
|
39
|
+
{recommended ? (
|
|
40
|
+
<Text size="xs" color="default" weight="semibold">
|
|
41
|
+
Recommended
|
|
42
|
+
</Text>
|
|
43
|
+
) : null}
|
|
44
|
+
|
|
45
|
+
<View style={styles.head}>
|
|
46
|
+
{rank != null ? (
|
|
47
|
+
<View style={styles.rank}>
|
|
48
|
+
<Text size="sm" weight="semibold" style={{ color: colors.zinc[700] }}>
|
|
49
|
+
{rank}
|
|
50
|
+
</Text>
|
|
51
|
+
</View>
|
|
52
|
+
) : null}
|
|
53
|
+
<View style={styles.titleCol}>
|
|
54
|
+
<Text size="md" weight="semibold" numberOfLines={1}>
|
|
55
|
+
{title}
|
|
56
|
+
</Text>
|
|
57
|
+
{subtitle ? (
|
|
58
|
+
<Text size="xs" color="muted" numberOfLines={1}>
|
|
59
|
+
{subtitle}
|
|
60
|
+
</Text>
|
|
61
|
+
) : null}
|
|
62
|
+
</View>
|
|
63
|
+
{pct != null ? (
|
|
64
|
+
<View style={styles.scoreCol}>
|
|
65
|
+
<Text size="lg" weight="semibold" tabular>
|
|
66
|
+
{pct}
|
|
67
|
+
</Text>
|
|
68
|
+
<Text size="xs" color="muted">
|
|
69
|
+
score
|
|
70
|
+
</Text>
|
|
71
|
+
</View>
|
|
72
|
+
) : null}
|
|
73
|
+
</View>
|
|
74
|
+
|
|
75
|
+
{pct != null ? (
|
|
76
|
+
<View style={styles.track}>
|
|
77
|
+
<View style={[styles.fill, { width: `${pct}%` }]} />
|
|
78
|
+
</View>
|
|
79
|
+
) : null}
|
|
80
|
+
|
|
81
|
+
{rationale ? (
|
|
82
|
+
<View style={styles.note}>
|
|
83
|
+
<Text size="sm" color="muted">
|
|
84
|
+
{rationale}
|
|
85
|
+
</Text>
|
|
86
|
+
</View>
|
|
87
|
+
) : null}
|
|
88
|
+
|
|
89
|
+
{specs && specs.length > 0 ? <SpecList rows={specs} dense /> : null}
|
|
90
|
+
|
|
91
|
+
{onSelect ? (
|
|
92
|
+
<View style={styles.footer}>
|
|
93
|
+
<Button
|
|
94
|
+
title={selected ? "Selected" : selectLabel ?? "Choose"}
|
|
95
|
+
icon={selected ? "check" : undefined}
|
|
96
|
+
color={selected ? "secondary" : "primary"}
|
|
97
|
+
shape="rounded"
|
|
98
|
+
onPress={onSelect}
|
|
99
|
+
/>
|
|
100
|
+
</View>
|
|
101
|
+
) : null}
|
|
102
|
+
</View>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const RING: ViewStyle = { boxShadow: `0 0 0 2px ${colors.zinc[900]}` } as ViewStyle;
|
|
107
|
+
|
|
108
|
+
const styles = StyleSheet.create({
|
|
109
|
+
card: {
|
|
110
|
+
borderWidth: 1,
|
|
111
|
+
borderColor: colors.border,
|
|
112
|
+
backgroundColor: colors.white,
|
|
113
|
+
borderRadius: 12,
|
|
114
|
+
padding: 16,
|
|
115
|
+
gap: 12,
|
|
116
|
+
},
|
|
117
|
+
recCard: { borderColor: colors.zinc[400] },
|
|
118
|
+
selected: RING,
|
|
119
|
+
footer: { flexDirection: "row", justifyContent: "flex-end" },
|
|
120
|
+
head: { flexDirection: "row", alignItems: "center", gap: 12 },
|
|
121
|
+
rank: {
|
|
122
|
+
width: 28,
|
|
123
|
+
height: 28,
|
|
124
|
+
borderRadius: 8,
|
|
125
|
+
alignItems: "center",
|
|
126
|
+
justifyContent: "center",
|
|
127
|
+
backgroundColor: colors.zinc[100],
|
|
128
|
+
},
|
|
129
|
+
titleCol: { flex: 1, gap: 1 },
|
|
130
|
+
scoreCol: { alignItems: "flex-end" },
|
|
131
|
+
track: {
|
|
132
|
+
height: 6,
|
|
133
|
+
borderRadius: 999,
|
|
134
|
+
backgroundColor: colors.zinc[100],
|
|
135
|
+
overflow: "hidden",
|
|
136
|
+
},
|
|
137
|
+
fill: { height: "100%", borderRadius: 999, backgroundColor: colors.zinc[900] },
|
|
138
|
+
note: { borderLeftWidth: 2, borderLeftColor: colors.zinc[300], paddingLeft: 10 },
|
|
139
|
+
});
|
package/src/sources.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { StyleSheet, View } from "react-native";
|
|
2
|
-
import { colors } from "./colors";
|
|
2
|
+
import { colors, solid, tint, type ColorName } from "./colors";
|
|
3
3
|
import { Text } from "./text";
|
|
4
4
|
import { Icon, type IconName } from "./icon";
|
|
5
5
|
import { PressableHighlight } from "./pressable_highlight";
|
|
@@ -10,7 +10,7 @@ export interface SourceRef {
|
|
|
10
10
|
id: string;
|
|
11
11
|
label: string;
|
|
12
12
|
kind?: SourceKind;
|
|
13
|
-
/** A locator
|
|
13
|
+
/** A locator after the label — "page 2", a record code, a column. */
|
|
14
14
|
detail?: string;
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -18,36 +18,41 @@ export interface SourcesProps {
|
|
|
18
18
|
sources: SourceRef[];
|
|
19
19
|
/** Eyebrow above the chips. Default "Sources". */
|
|
20
20
|
label?: string;
|
|
21
|
-
/** Pass to make each chip openable (jump to the record / document / page).
|
|
21
|
+
/** Pass to make each chip openable (jump to the record / document / page). The
|
|
22
|
+
* chips then show a hover state + an open glyph; the host does the navigation. */
|
|
22
23
|
onOpen?: (source: SourceRef) => void;
|
|
23
24
|
}
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
// Each kind carries its own glyph + colour — provenance you can recognise at a
|
|
27
|
+
// glance (echoing the file-badge palette: document red, table green, …).
|
|
28
|
+
const KIND: Record<SourceKind, { icon: IconName; color: ColorName }> = {
|
|
29
|
+
document: { icon: "file-text", color: "red" },
|
|
30
|
+
table: { icon: "table-2", color: "emerald" },
|
|
31
|
+
record: { icon: "box", color: "blue" },
|
|
32
|
+
knowledge: { icon: "book-open", color: "amber" },
|
|
33
|
+
web: { icon: "globe", color: "sky" },
|
|
31
34
|
};
|
|
32
35
|
|
|
33
36
|
/**
|
|
34
37
|
* Where the AI's output came FROM — a row of source chips under a generated
|
|
35
|
-
* answer, summary, or extracted value, each
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
+
* answer, summary, or extracted value, each with a per-kind coloured glyph.
|
|
39
|
+
* When `onOpen` is set each chip is pressable (hover wash + an open glyph) and
|
|
40
|
+
* the host navigates to the record / document / page. Provenance is what makes
|
|
41
|
+
* AI output verifiable; show it on anything the agent produced from data it read.
|
|
42
|
+
* Renders nothing for an empty list.
|
|
38
43
|
*/
|
|
39
44
|
export function Sources(props: SourcesProps) {
|
|
40
45
|
if (props.sources.length === 0) return null;
|
|
41
46
|
return (
|
|
42
|
-
<View style={{ gap:
|
|
43
|
-
<Text size="xs" color="muted"
|
|
47
|
+
<View style={{ gap: 8 }}>
|
|
48
|
+
<Text size="xs" color="muted" weight="medium">
|
|
44
49
|
{props.label ?? "Sources"}
|
|
45
50
|
</Text>
|
|
46
51
|
<View style={styles.row}>
|
|
47
52
|
{props.sources.map((s) =>
|
|
48
53
|
props.onOpen ? (
|
|
49
|
-
<PressableHighlight key={s.id} onPress={() => props.onOpen?.(s)} style={styles.chip}>
|
|
50
|
-
<SourceChip source={s} />
|
|
54
|
+
<PressableHighlight key={s.id} onPress={() => props.onOpen?.(s)} accessibilityLabel={`Open ${s.label}`} style={styles.chip}>
|
|
55
|
+
<SourceChip source={s} openable />
|
|
51
56
|
</PressableHighlight>
|
|
52
57
|
) : (
|
|
53
58
|
<View key={s.id} style={styles.chip}>
|
|
@@ -60,10 +65,13 @@ export function Sources(props: SourcesProps) {
|
|
|
60
65
|
);
|
|
61
66
|
}
|
|
62
67
|
|
|
63
|
-
function SourceChip({ source }: { source: SourceRef }) {
|
|
68
|
+
function SourceChip({ source, openable }: { source: SourceRef; openable?: boolean }) {
|
|
69
|
+
const k = KIND[source.kind ?? "document"];
|
|
64
70
|
return (
|
|
65
71
|
<>
|
|
66
|
-
<
|
|
72
|
+
<View style={[styles.iconDisc, { backgroundColor: tint(k.color, 0.14) }]}>
|
|
73
|
+
<Icon name={k.icon} size={13} color={solid(k.color)} />
|
|
74
|
+
</View>
|
|
67
75
|
<Text size="xs" weight="medium" numberOfLines={1}>
|
|
68
76
|
{source.label}
|
|
69
77
|
</Text>
|
|
@@ -72,6 +80,7 @@ function SourceChip({ source }: { source: SourceRef }) {
|
|
|
72
80
|
{source.detail}
|
|
73
81
|
</Text>
|
|
74
82
|
) : null}
|
|
83
|
+
{openable ? <Icon name="external-link" size={12} color={colors.zinc[400]} /> : null}
|
|
75
84
|
</>
|
|
76
85
|
);
|
|
77
86
|
}
|
|
@@ -81,12 +90,20 @@ const styles = StyleSheet.create({
|
|
|
81
90
|
chip: {
|
|
82
91
|
flexDirection: "row",
|
|
83
92
|
alignItems: "center",
|
|
84
|
-
gap:
|
|
85
|
-
|
|
93
|
+
gap: 8,
|
|
94
|
+
paddingLeft: 5,
|
|
95
|
+
paddingRight: 10,
|
|
86
96
|
paddingVertical: 5,
|
|
87
|
-
borderRadius:
|
|
97
|
+
borderRadius: 10,
|
|
88
98
|
borderWidth: 1,
|
|
89
99
|
borderColor: colors.border,
|
|
90
100
|
backgroundColor: colors.white,
|
|
91
101
|
},
|
|
102
|
+
iconDisc: {
|
|
103
|
+
width: 24,
|
|
104
|
+
height: 24,
|
|
105
|
+
borderRadius: 7,
|
|
106
|
+
alignItems: "center",
|
|
107
|
+
justifyContent: "center",
|
|
108
|
+
},
|
|
92
109
|
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { Text } from "./text";
|
|
3
|
+
import { Divider } from "./divider";
|
|
4
|
+
|
|
5
|
+
export interface SpecRow {
|
|
6
|
+
/** The line label (left). */
|
|
7
|
+
label: string;
|
|
8
|
+
/** The value (right). A number renders grouped + tabular. */
|
|
9
|
+
value: string | number;
|
|
10
|
+
/** Unit after the value (%, mm, kg, ₫). */
|
|
11
|
+
unit?: string;
|
|
12
|
+
/** A heavier value — a headline figure or a sub-total inside the list. */
|
|
13
|
+
emphasis?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SpecListProps {
|
|
17
|
+
rows: SpecRow[];
|
|
18
|
+
/** A pinned summary row, set off by a divider above and a heavier weight —
|
|
19
|
+
* the total of a duty / cost / dimension breakdown. */
|
|
20
|
+
total?: SpecRow;
|
|
21
|
+
/** Tighter rows + xs labels for a dense side panel. */
|
|
22
|
+
dense?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A labeled-value breakdown — the structured "spec sheet": a duty table, a
|
|
27
|
+
* dimensions list, a cost split, the exact numbers behind an answer. Each row is
|
|
28
|
+
* just label → value (· unit); an optional `total` pins the sum under a divider.
|
|
29
|
+
* Deliberately minimal — two columns, hierarchy from weight and tabular figures.
|
|
30
|
+
* The right-hand panel of a lookup / answer surface, the specs of a compared
|
|
31
|
+
* option.
|
|
32
|
+
*/
|
|
33
|
+
export function SpecList({ rows, total, dense }: SpecListProps) {
|
|
34
|
+
return (
|
|
35
|
+
<View>
|
|
36
|
+
{rows.map((r, i) => (
|
|
37
|
+
<SpecLine key={`${r.label}-${i}`} row={r} dense={dense} />
|
|
38
|
+
))}
|
|
39
|
+
{total ? (
|
|
40
|
+
<>
|
|
41
|
+
<Divider paddingVertical={dense ? 5 : 7} />
|
|
42
|
+
<SpecLine row={{ ...total, emphasis: true }} dense={dense} isTotal />
|
|
43
|
+
</>
|
|
44
|
+
) : null}
|
|
45
|
+
</View>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function SpecLine({ row, dense, isTotal }: { row: SpecRow; dense?: boolean; isTotal?: boolean }) {
|
|
50
|
+
const { label, value, unit, emphasis } = row;
|
|
51
|
+
const display = typeof value === "number" ? value.toLocaleString("en-US") : value;
|
|
52
|
+
return (
|
|
53
|
+
<View style={[styles.row, { minHeight: dense ? 26 : 32 }]}>
|
|
54
|
+
<Text
|
|
55
|
+
size={dense ? "xs" : "sm"}
|
|
56
|
+
color={isTotal ? "default" : "muted"}
|
|
57
|
+
weight={isTotal ? "semibold" : "regular"}
|
|
58
|
+
numberOfLines={1}
|
|
59
|
+
style={styles.labelText}
|
|
60
|
+
>
|
|
61
|
+
{label}
|
|
62
|
+
</Text>
|
|
63
|
+
<View style={styles.valueRow}>
|
|
64
|
+
<Text size={dense && !isTotal ? "sm" : "md"} weight={emphasis ? "semibold" : "medium"} tabular>
|
|
65
|
+
{display}
|
|
66
|
+
</Text>
|
|
67
|
+
{unit ? (
|
|
68
|
+
<Text size={dense ? "xs" : "sm"} color="muted">
|
|
69
|
+
{unit}
|
|
70
|
+
</Text>
|
|
71
|
+
) : null}
|
|
72
|
+
</View>
|
|
73
|
+
</View>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const styles = StyleSheet.create({
|
|
78
|
+
row: { flexDirection: "row", alignItems: "baseline", justifyContent: "space-between", gap: 16 },
|
|
79
|
+
labelText: { flexShrink: 1 },
|
|
80
|
+
valueRow: { flexDirection: "row", alignItems: "baseline", gap: 4 },
|
|
81
|
+
});
|