@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.
- package/AGENTS.md +98 -28
- 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/discrepancy.tsx +114 -0
- package/src/finding.tsx +104 -0
- 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/triage_row.tsx +99 -0
- package/src/assisted_field.tsx +0 -93
|
@@ -0,0 +1,114 @@
|
|
|
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 { Badge } from "./badge";
|
|
6
|
+
import { Button } from "./button";
|
|
7
|
+
import { CardSelectItem } from "./card_select_item";
|
|
8
|
+
|
|
9
|
+
export interface DiscrepancyValue {
|
|
10
|
+
/** Where this value came from — the document / record / system of record. */
|
|
11
|
+
source: string;
|
|
12
|
+
value: string;
|
|
13
|
+
/** The agent's recommended truth among the conflicting values. */
|
|
14
|
+
recommended?: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DiscrepancyProps {
|
|
18
|
+
/** The field that disagrees across sources. */
|
|
19
|
+
field: string;
|
|
20
|
+
/** The agent's explanation of the conflict — shown above the options so the
|
|
21
|
+
* human reads WHY before picking. */
|
|
22
|
+
note?: string;
|
|
23
|
+
values: DiscrepancyValue[];
|
|
24
|
+
/** Resolve the conflict by picking a value (index into `values`) — pressing a
|
|
25
|
+
* card commits it; there is no separate confirm button. */
|
|
26
|
+
onResolve?: (index: number) => void;
|
|
27
|
+
/** Send for manual handling instead of picking. */
|
|
28
|
+
onFlag?: () => void;
|
|
29
|
+
/** Resolved → the chosen index settles the card. */
|
|
30
|
+
resolvedIndex?: number;
|
|
31
|
+
flagged?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A field whose value DISAGREES across sources — the unit of an AI cross-check
|
|
36
|
+
* / audit. The agent's explanation sits up top; below it the conflicting values
|
|
37
|
+
* are identical selectable cards (the global press/hover ring), the one the
|
|
38
|
+
* agent believes carrying a neutral "Agent's pick" tag — NOT a pre-selected
|
|
39
|
+
* highlight. Pressing a card resolves the conflict to it — no confirm step.
|
|
40
|
+
* Symmetric, unlike `ChangeReview`'s before→after.
|
|
41
|
+
*/
|
|
42
|
+
export function Discrepancy(props: DiscrepancyProps) {
|
|
43
|
+
const { field, values, note, onResolve, onFlag, resolvedIndex, flagged } = props;
|
|
44
|
+
const resolved = resolvedIndex != null || flagged === true;
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<View style={[styles.card, resolved ? styles.resolved : null]}>
|
|
48
|
+
<Text size="sm" weight="semibold" numberOfLines={1}>
|
|
49
|
+
{field}
|
|
50
|
+
</Text>
|
|
51
|
+
|
|
52
|
+
{resolved ? (
|
|
53
|
+
<View style={styles.outcome}>
|
|
54
|
+
<Icon name={flagged ? "triangle-alert" : "circle-check"} size={15} color={flagged ? solid("amber") : solid("emerald")} />
|
|
55
|
+
<Text size="sm" color="muted">
|
|
56
|
+
{flagged
|
|
57
|
+
? "Flagged for manual review"
|
|
58
|
+
: `Resolved to ${values[resolvedIndex as number].source} — ${values[resolvedIndex as number].value}`}
|
|
59
|
+
</Text>
|
|
60
|
+
</View>
|
|
61
|
+
) : (
|
|
62
|
+
<>
|
|
63
|
+
{note ? (
|
|
64
|
+
<Text size="sm" color="muted">
|
|
65
|
+
{note}
|
|
66
|
+
</Text>
|
|
67
|
+
) : null}
|
|
68
|
+
|
|
69
|
+
<View style={styles.values}>
|
|
70
|
+
{values.map((v, i) => (
|
|
71
|
+
<CardSelectItem
|
|
72
|
+
key={`${v.source}-${i}`}
|
|
73
|
+
accessibilityLabel={`Resolve ${field} to ${v.source}: ${v.value}`}
|
|
74
|
+
onPress={() => onResolve?.(i)}
|
|
75
|
+
style={styles.valueBox}
|
|
76
|
+
>
|
|
77
|
+
<Text size="sm" color="muted" numberOfLines={1} style={{ flexShrink: 1 }}>
|
|
78
|
+
{v.source}
|
|
79
|
+
</Text>
|
|
80
|
+
<View style={{ flex: 1 }} />
|
|
81
|
+
{v.recommended ? <Badge label="Agent's pick" /> : null}
|
|
82
|
+
<Text size="md" weight="semibold" tabular>
|
|
83
|
+
{v.value}
|
|
84
|
+
</Text>
|
|
85
|
+
</CardSelectItem>
|
|
86
|
+
))}
|
|
87
|
+
</View>
|
|
88
|
+
|
|
89
|
+
{onFlag ? (
|
|
90
|
+
<View style={styles.footer}>
|
|
91
|
+
<Button title="Flag for review" color="muted" shape="rounded" onPress={onFlag} />
|
|
92
|
+
</View>
|
|
93
|
+
) : null}
|
|
94
|
+
</>
|
|
95
|
+
)}
|
|
96
|
+
</View>
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const styles = StyleSheet.create({
|
|
101
|
+
card: {
|
|
102
|
+
borderWidth: 1,
|
|
103
|
+
borderColor: colors.border,
|
|
104
|
+
backgroundColor: colors.white,
|
|
105
|
+
borderRadius: 12,
|
|
106
|
+
padding: 16,
|
|
107
|
+
gap: 12,
|
|
108
|
+
},
|
|
109
|
+
resolved: { backgroundColor: colors.zinc[50] },
|
|
110
|
+
outcome: { flexDirection: "row", alignItems: "center", gap: 8 },
|
|
111
|
+
values: { gap: 8 },
|
|
112
|
+
valueBox: { flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 12, paddingHorizontal: 14 },
|
|
113
|
+
footer: { flexDirection: "row", justifyContent: "flex-end" },
|
|
114
|
+
});
|
package/src/finding.tsx
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { colors, solid, type ColorName } from "./colors";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Badge } from "./badge";
|
|
5
|
+
import { Button } from "./button";
|
|
6
|
+
import { Sources, type SourceRef } from "./sources";
|
|
7
|
+
|
|
8
|
+
export type FindingSeverity = "critical" | "warning" | "info" | "positive";
|
|
9
|
+
|
|
10
|
+
export interface FindingProps {
|
|
11
|
+
severity?: FindingSeverity;
|
|
12
|
+
/** The headline — what the agent found. */
|
|
13
|
+
title: string;
|
|
14
|
+
/** One or two lines explaining it. */
|
|
15
|
+
detail?: string;
|
|
16
|
+
/** A headline figure tied to the finding (a value, a delta, a count), shown in
|
|
17
|
+
* the header row in the severity colour. */
|
|
18
|
+
metric?: string;
|
|
19
|
+
/** A short caption beside the metric giving it context ("exposure", "overdue",
|
|
20
|
+
* "vs target"). */
|
|
21
|
+
metricCaption?: string;
|
|
22
|
+
/** Provenance — the records / documents the finding rests on. */
|
|
23
|
+
sources?: SourceRef[];
|
|
24
|
+
onOpenSource?: (s: SourceRef) => void;
|
|
25
|
+
/** The single action the finding suggests. */
|
|
26
|
+
action?: { label: string; onPress: () => void };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Severity reads through a coloured dot badge + the order it's stacked in (most
|
|
30
|
+
// severe first): red critical, amber warning, blue note, emerald on-track. The
|
|
31
|
+
// metric takes the same colour.
|
|
32
|
+
const SEV: Record<FindingSeverity, { word: string; color: ColorName }> = {
|
|
33
|
+
critical: { word: "Critical", color: "red" },
|
|
34
|
+
warning: { word: "Warning", color: "amber" },
|
|
35
|
+
info: { word: "Note", color: "blue" },
|
|
36
|
+
positive: { word: "On track", color: "emerald" },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* One ranked insight from an AI briefing / audit / anomaly scan — a severity dot
|
|
41
|
+
* badge and a headline figure share the top row, the headline + explanation read
|
|
42
|
+
* below, then provenance and the single action it suggests. Severity reads
|
|
43
|
+
* through the coloured badge + metric and the stacking order (most severe first).
|
|
44
|
+
* Unlike `Callout` (a flat inline status), a Finding is ranked, sourced, and
|
|
45
|
+
* carries its own next action.
|
|
46
|
+
*/
|
|
47
|
+
export function Finding(props: FindingProps) {
|
|
48
|
+
const sev = SEV[props.severity ?? "info"];
|
|
49
|
+
return (
|
|
50
|
+
<View style={styles.card}>
|
|
51
|
+
<View style={styles.header}>
|
|
52
|
+
<Badge variant="dot" color={sev.color} label={sev.word} />
|
|
53
|
+
<View style={{ flex: 1 }} />
|
|
54
|
+
{props.metric ? (
|
|
55
|
+
<View style={styles.metric}>
|
|
56
|
+
<Text size="lg" weight="semibold" tabular style={{ color: solid(sev.color), letterSpacing: -0.3 }}>
|
|
57
|
+
{props.metric}
|
|
58
|
+
</Text>
|
|
59
|
+
{props.metricCaption ? (
|
|
60
|
+
<Text size="xs" color="muted">
|
|
61
|
+
{props.metricCaption}
|
|
62
|
+
</Text>
|
|
63
|
+
) : null}
|
|
64
|
+
</View>
|
|
65
|
+
) : null}
|
|
66
|
+
</View>
|
|
67
|
+
|
|
68
|
+
<View style={styles.body}>
|
|
69
|
+
<Text size="md" weight="semibold" numberOfLines={2}>
|
|
70
|
+
{props.title}
|
|
71
|
+
</Text>
|
|
72
|
+
{props.detail ? (
|
|
73
|
+
<Text size="sm" color="muted">
|
|
74
|
+
{props.detail}
|
|
75
|
+
</Text>
|
|
76
|
+
) : null}
|
|
77
|
+
</View>
|
|
78
|
+
|
|
79
|
+
{props.sources && props.sources.length > 0 ? (
|
|
80
|
+
<Sources sources={props.sources} onOpen={props.onOpenSource} />
|
|
81
|
+
) : null}
|
|
82
|
+
{props.action ? (
|
|
83
|
+
<View style={styles.actionRow}>
|
|
84
|
+
<Button title={props.action.label} color="secondary" shape="rounded" onPress={props.action.onPress} />
|
|
85
|
+
</View>
|
|
86
|
+
) : null}
|
|
87
|
+
</View>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const styles = StyleSheet.create({
|
|
92
|
+
card: {
|
|
93
|
+
borderWidth: 1,
|
|
94
|
+
borderColor: colors.border,
|
|
95
|
+
backgroundColor: colors.white,
|
|
96
|
+
borderRadius: 12,
|
|
97
|
+
padding: 16,
|
|
98
|
+
gap: 12,
|
|
99
|
+
},
|
|
100
|
+
header: { flexDirection: "row", alignItems: "center", gap: 12 },
|
|
101
|
+
metric: { flexDirection: "row", alignItems: "baseline", gap: 5 },
|
|
102
|
+
body: { gap: 6 },
|
|
103
|
+
actionRow: { flexDirection: "row", justifyContent: "flex-end" },
|
|
104
|
+
});
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { StyleSheet, View } from "react-native";
|
|
2
|
+
import { colors } from "./colors";
|
|
3
|
+
import { Text } from "./text";
|
|
4
|
+
import { Icon } from "./icon";
|
|
5
|
+
import { Button } from "./button";
|
|
6
|
+
import { Confidence, type ConfidenceLevel } from "./confidence";
|
|
7
|
+
|
|
8
|
+
export interface MatchSide {
|
|
9
|
+
/** Primary line — the record name / id. */
|
|
10
|
+
title: string;
|
|
11
|
+
/** Secondary line — amount, date, customer. */
|
|
12
|
+
detail?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface MatchRowProps {
|
|
16
|
+
/** The known item we're finding a counterpart for. */
|
|
17
|
+
source: MatchSide;
|
|
18
|
+
/** The agent's proposed counterpart. Omit → the "no confident match" state. */
|
|
19
|
+
match?: MatchSide;
|
|
20
|
+
/** One line of WHY the agent paired them. */
|
|
21
|
+
rationale?: string;
|
|
22
|
+
confidence?: ConfidenceLevel;
|
|
23
|
+
confidenceScore?: number;
|
|
24
|
+
onAccept?: () => void;
|
|
25
|
+
/** Pick a different counterpart — the host opens its candidate list. */
|
|
26
|
+
onReassign?: () => void;
|
|
27
|
+
onDismiss?: () => void;
|
|
28
|
+
acceptLabel?: string;
|
|
29
|
+
/** Resolved → settles to a quiet outcome line. */
|
|
30
|
+
status?: "open" | "accepted" | "dismissed";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* An AI-proposed PAIRING — a known item, an arrow, the agent's proposed
|
|
35
|
+
* counterpart; a confidence meter and one muted line of why. The human accepts,
|
|
36
|
+
* reassigns, or rejects; nothing links on its own. Deliberately spare: two
|
|
37
|
+
* sides, an arrow, the reason. The unit of an AI reconciliation / dedup /
|
|
38
|
+
* correlation queue. Unlike `Suggestion` (a single proposed value) this is
|
|
39
|
+
* two-sided; unlike the deterministic reconcile recipe, the agent reasons it.
|
|
40
|
+
*/
|
|
41
|
+
export function MatchRow(props: MatchRowProps) {
|
|
42
|
+
const status = props.status ?? "open";
|
|
43
|
+
const resolved = status !== "open";
|
|
44
|
+
const hasMatch = props.match != null;
|
|
45
|
+
const hasConfidence = props.confidence != null || props.confidenceScore != null;
|
|
46
|
+
|
|
47
|
+
if (resolved) {
|
|
48
|
+
return (
|
|
49
|
+
<View style={[styles.card, styles.resolved]}>
|
|
50
|
+
<View style={styles.pair}>
|
|
51
|
+
<Side side={props.source} />
|
|
52
|
+
</View>
|
|
53
|
+
<Text size="xs" color="muted" weight="medium">
|
|
54
|
+
{status === "accepted" ? (hasMatch ? `Matched · ${props.match!.title}` : "Matched") : "Dismissed"}
|
|
55
|
+
</Text>
|
|
56
|
+
</View>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<View style={styles.card}>
|
|
62
|
+
<View style={styles.pair}>
|
|
63
|
+
<Side side={props.source} />
|
|
64
|
+
<Icon name="arrow-right" size={16} color={colors.zinc[400]} />
|
|
65
|
+
{hasMatch ? (
|
|
66
|
+
<Side side={props.match!} right />
|
|
67
|
+
) : (
|
|
68
|
+
<View style={[styles.side, styles.sideRight]}>
|
|
69
|
+
<Text size="sm" weight="medium" color="muted" align="right" numberOfLines={1}>
|
|
70
|
+
No confident match
|
|
71
|
+
</Text>
|
|
72
|
+
<Text size="xs" color="muted" align="right" numberOfLines={1}>
|
|
73
|
+
Pick a counterpart
|
|
74
|
+
</Text>
|
|
75
|
+
</View>
|
|
76
|
+
)}
|
|
77
|
+
</View>
|
|
78
|
+
|
|
79
|
+
{hasConfidence || props.rationale ? (
|
|
80
|
+
<View style={styles.meta}>
|
|
81
|
+
{hasConfidence ? <Confidence level={props.confidence} score={props.confidenceScore} /> : null}
|
|
82
|
+
{props.rationale ? (
|
|
83
|
+
<Text size="xs" color="muted" style={{ flex: 1 }} numberOfLines={2}>
|
|
84
|
+
{props.rationale}
|
|
85
|
+
</Text>
|
|
86
|
+
) : null}
|
|
87
|
+
</View>
|
|
88
|
+
) : null}
|
|
89
|
+
|
|
90
|
+
<View style={styles.footer}>
|
|
91
|
+
{props.onDismiss ? <Button title="Not a match" color="muted" shape="rounded" onPress={props.onDismiss} /> : null}
|
|
92
|
+
{props.onReassign ? (
|
|
93
|
+
<Button title={hasMatch ? "Reassign" : "Find match"} color="secondary" shape="rounded" onPress={props.onReassign} />
|
|
94
|
+
) : null}
|
|
95
|
+
{hasMatch && props.onAccept ? (
|
|
96
|
+
<Button title={props.acceptLabel ?? "Accept"} color="primary" shape="rounded" onPress={props.onAccept} />
|
|
97
|
+
) : null}
|
|
98
|
+
</View>
|
|
99
|
+
</View>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function Side({ side, right }: { side: MatchSide; right?: boolean }) {
|
|
104
|
+
return (
|
|
105
|
+
<View style={[styles.side, right ? styles.sideRight : null]}>
|
|
106
|
+
<Text size="sm" weight="medium" numberOfLines={1} align={right ? "right" : undefined}>
|
|
107
|
+
{side.title}
|
|
108
|
+
</Text>
|
|
109
|
+
{side.detail ? (
|
|
110
|
+
<Text size="xs" color="muted" numberOfLines={1} align={right ? "right" : undefined}>
|
|
111
|
+
{side.detail}
|
|
112
|
+
</Text>
|
|
113
|
+
) : null}
|
|
114
|
+
</View>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const styles = StyleSheet.create({
|
|
119
|
+
card: {
|
|
120
|
+
borderWidth: 1,
|
|
121
|
+
borderColor: colors.border,
|
|
122
|
+
backgroundColor: colors.white,
|
|
123
|
+
borderRadius: 12,
|
|
124
|
+
padding: 16,
|
|
125
|
+
gap: 12,
|
|
126
|
+
},
|
|
127
|
+
resolved: { backgroundColor: colors.zinc[50] },
|
|
128
|
+
pair: { flexDirection: "row", alignItems: "center", gap: 12 },
|
|
129
|
+
side: { flex: 1, gap: 2 },
|
|
130
|
+
sideRight: { alignItems: "flex-end" },
|
|
131
|
+
meta: { flexDirection: "row", alignItems: "center", gap: 12 },
|
|
132
|
+
footer: { flexDirection: "row", alignItems: "center", justifyContent: "flex-end", gap: 8 },
|
|
133
|
+
});
|
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
|
+
});
|