@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.
@@ -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
+ });
@@ -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
+ });
@@ -6,38 +6,38 @@ import {
6
6
  TextInputKeyPressEventData,
7
7
  View,
8
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";
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 (without Shift) or the send button. Receives the trimmed
20
- * text; never fires empty or while busy. */
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 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. */
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 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. */
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 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.
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
- 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>
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
- borderWidth: 1,
90
+ flexDirection: "row",
91
+ alignItems: "center",
92
+ gap: 6,
93
+ minHeight: 52,
94
+ borderWidth: 1.5,
120
95
  borderColor: colors.border,
121
- borderRadius: 14,
96
+ borderRadius: 999,
122
97
  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,
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
- minHeight: 48,
141
- maxHeight: 160,
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
+ });