@lotics/ui 7.19.3 → 8.0.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 +128 -92
- package/examples/tpl_dieline.tsx +3 -3
- package/examples/tpl_documents.tsx +790 -0
- package/examples/tpl_lookup.tsx +37 -10
- package/examples/tpl_task_board.tsx +32 -19
- package/examples/tpl_tasks.tsx +32 -19
- package/package.json +2 -9
- package/src/agent_run.tsx +1 -1
- package/src/change_review.tsx +732 -236
- package/src/composer.tsx +22 -1
- package/src/confidence.tsx +1 -1
- package/src/control_surface.ts +0 -14
- package/src/finding.tsx +112 -80
- package/src/floating_action_bar.tsx +1 -1
- package/src/locale.tsx +22 -16
- package/src/sources.tsx +8 -5
- package/examples/tpl_assistant.tsx +0 -174
- package/examples/tpl_briefing.tsx +0 -121
- package/examples/tpl_compare.tsx +0 -133
- package/examples/tpl_crosscheck.tsx +0 -120
- package/examples/tpl_draft.tsx +0 -163
- package/examples/tpl_extract.tsx +0 -193
- package/examples/tpl_match.tsx +0 -134
- package/examples/tpl_ratedesk.tsx +0 -386
- package/examples/tpl_triage.tsx +0 -112
- package/src/discrepancy.tsx +0 -114
- package/src/match_sides.tsx +0 -79
- package/src/record_fields.tsx +0 -68
- package/src/review_card.tsx +0 -172
- package/src/scored_option.tsx +0 -138
- package/src/spec_list.tsx +0 -81
- package/src/triage_row.tsx +0 -99
package/src/composer.tsx
CHANGED
|
@@ -2,6 +2,7 @@ import React, { type ReactNode, type Ref, useCallback, useEffect, useState } fro
|
|
|
2
2
|
import { View, TextInput as RNTextInput, ScrollView, StyleSheet, type TextInputProps } from "react-native";
|
|
3
3
|
import { IconButton } from "./icon_button";
|
|
4
4
|
import { colors } from "./colors";
|
|
5
|
+
import { Text } from "./text";
|
|
5
6
|
import { fontFamilyRegular, getInputTextStyle } from "./text_utils";
|
|
6
7
|
import { useAutoGrowHeight } from "./use_auto_grow_height";
|
|
7
8
|
|
|
@@ -189,7 +190,6 @@ export function Composer(props: ComposerProps) {
|
|
|
189
190
|
autoFocus={autoFocus}
|
|
190
191
|
onChangeText={handleChangeText}
|
|
191
192
|
onSubmitEditing={handleSend}
|
|
192
|
-
placeholder={placeholder}
|
|
193
193
|
placeholderTextColor={colors.zinc[500]}
|
|
194
194
|
value={text}
|
|
195
195
|
onFocus={() => setFocused(true)}
|
|
@@ -208,6 +208,15 @@ export function Composer(props: ComposerProps) {
|
|
|
208
208
|
spreadInputProps?.style,
|
|
209
209
|
]}
|
|
210
210
|
/>
|
|
211
|
+
{/* The placeholder is OURS, not the textarea's: a long hint must never
|
|
212
|
+
wrap into clipped lines inside the pill — one line, ellipsized. */}
|
|
213
|
+
{emptyText && placeholder ? (
|
|
214
|
+
<View pointerEvents="none" style={[styles.placeholderOverlay, expanded ? styles.placeholderOverlayExpanded : { paddingHorizontal: 10 }]}>
|
|
215
|
+
<Text size="sm" numberOfLines={1} style={{ color: colors.zinc[500], letterSpacing: -0.4 }}>
|
|
216
|
+
{placeholder}
|
|
217
|
+
</Text>
|
|
218
|
+
</View>
|
|
219
|
+
) : null}
|
|
211
220
|
</View>
|
|
212
221
|
);
|
|
213
222
|
|
|
@@ -314,6 +323,18 @@ const styles = StyleSheet.create({
|
|
|
314
323
|
field: {
|
|
315
324
|
flex: 1,
|
|
316
325
|
},
|
|
326
|
+
placeholderOverlay: {
|
|
327
|
+
position: "absolute",
|
|
328
|
+
left: 0,
|
|
329
|
+
right: 0,
|
|
330
|
+
top: 0,
|
|
331
|
+
bottom: 0,
|
|
332
|
+
justifyContent: "center",
|
|
333
|
+
},
|
|
334
|
+
placeholderOverlayExpanded: {
|
|
335
|
+
justifyContent: "flex-start",
|
|
336
|
+
paddingTop: 2,
|
|
337
|
+
},
|
|
317
338
|
textInput: {
|
|
318
339
|
flex: 1,
|
|
319
340
|
fontFamily: fontFamilyRegular,
|
package/src/confidence.tsx
CHANGED
|
@@ -36,7 +36,7 @@ export function levelFromScore(score: number): ConfidenceLevel {
|
|
|
36
36
|
* How sure the AI is — a calibrated high / medium / low read as a 3-tick meter
|
|
37
37
|
* (emerald / amber / zinc) beside the level word. The fill count and the colour
|
|
38
38
|
* both carry the level. The human weights an AI proposal or estimate by it. Pair
|
|
39
|
-
* with `
|
|
39
|
+
* with `ChangeReview`.
|
|
40
40
|
*/
|
|
41
41
|
export function Confidence(props: ConfidenceProps) {
|
|
42
42
|
const level = props.level ?? (props.score != null ? levelFromScore(props.score) : "medium");
|
package/src/control_surface.ts
CHANGED
|
@@ -39,20 +39,6 @@ export const CONTROL_TRANSITION = {
|
|
|
39
39
|
transitionDuration: "0.12s",
|
|
40
40
|
};
|
|
41
41
|
|
|
42
|
-
/** THE review-card surface — the bordered white card every "AI proposes, you
|
|
43
|
-
* decide" surface wears (`ReviewCard`, and the whole-set `ChangeReview`), so the
|
|
44
|
-
* review family reads as ONE contract instead of look-alikes drifting apart. One
|
|
45
|
-
* definition; each adds its own header / body / footer on top.
|
|
46
|
-
* `reviewResolvedStyle` is the settled wash once decided. */
|
|
47
|
-
export const reviewCardStyle: ViewStyle = {
|
|
48
|
-
borderWidth: 1,
|
|
49
|
-
borderColor: colors.border,
|
|
50
|
-
backgroundColor: colors.white,
|
|
51
|
-
borderRadius: 12,
|
|
52
|
-
padding: 16,
|
|
53
|
-
gap: 12,
|
|
54
|
-
};
|
|
55
|
-
export const reviewResolvedStyle: ViewStyle = { backgroundColor: colors.zinc[50] };
|
|
56
42
|
|
|
57
43
|
/**
|
|
58
44
|
* THE pill-surface contract — the single definition of the bordered, white,
|
package/src/finding.tsx
CHANGED
|
@@ -1,110 +1,142 @@
|
|
|
1
|
-
import { StyleSheet, View } from "react-native";
|
|
2
|
-
import {
|
|
3
|
-
import { Text } from "./text";
|
|
1
|
+
import { StyleSheet, View, type StyleProp, type ViewStyle } from "react-native";
|
|
2
|
+
import { type ReactNode } from "react";
|
|
4
3
|
import { Badge } from "./badge";
|
|
5
|
-
import {
|
|
4
|
+
import { Text } from "./text";
|
|
6
5
|
import { Sources, type SourceRef } from "./sources";
|
|
7
6
|
import { useLoticsLocale } from "./locale";
|
|
7
|
+
import { colors, type ColorName } from "./colors";
|
|
8
8
|
|
|
9
9
|
export type FindingSeverity = "critical" | "warning" | "info" | "positive";
|
|
10
10
|
|
|
11
|
+
/** The localized strings — the `finding` LoticsLocale slice. */
|
|
12
|
+
export type FindingLabels = Record<FindingSeverity, string> & {
|
|
13
|
+
/** The delta row's label in `FindingComparison`. */
|
|
14
|
+
difference: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
const SEVERITY_COLOR: Record<FindingSeverity, ColorName> = {
|
|
18
|
+
critical: "red",
|
|
19
|
+
warning: "amber",
|
|
20
|
+
info: "zinc",
|
|
21
|
+
positive: "emerald",
|
|
22
|
+
};
|
|
23
|
+
|
|
11
24
|
export interface FindingProps {
|
|
12
|
-
severity
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
/** The headline — what the agent found. */
|
|
25
|
+
/** Ranked severity — the dot badge + its localized word. */
|
|
26
|
+
severity: FindingSeverity;
|
|
27
|
+
/** What the agent found — one line. */
|
|
16
28
|
title: string;
|
|
17
|
-
/**
|
|
29
|
+
/** The explanation under the title. */
|
|
18
30
|
detail?: string;
|
|
19
|
-
/**
|
|
20
|
-
*
|
|
31
|
+
/** The emphasized MEASURE of the finding ("−40 pcs") — prominent in the
|
|
32
|
+
* body (lg, semibold), never a side note. */
|
|
21
33
|
metric?: string;
|
|
22
|
-
/**
|
|
23
|
-
* "vs target"). */
|
|
34
|
+
/** What the metric compares ("invoice vs packing list"). */
|
|
24
35
|
metricCaption?: string;
|
|
25
|
-
/** Provenance — the
|
|
36
|
+
/** Provenance — the one `Sources` idiom, chips at the bottom. */
|
|
26
37
|
sources?: SourceRef[];
|
|
27
|
-
onOpenSource?: (
|
|
28
|
-
/**
|
|
29
|
-
action
|
|
38
|
+
onOpenSource?: (source: SourceRef) => void;
|
|
39
|
+
/** Compose anything extra between the body and the sources — a band pair,
|
|
40
|
+
* an action row, a custom block. */
|
|
41
|
+
children?: ReactNode;
|
|
42
|
+
style?: StyleProp<ViewStyle>;
|
|
30
43
|
}
|
|
31
44
|
|
|
32
|
-
// Severity reads through a coloured dot badge + the order it's stacked in (most
|
|
33
|
-
// severe first): red critical, amber warning, blue note, emerald on-track. The
|
|
34
|
-
// metric takes the same colour. Words resolve prop → locale → English default.
|
|
35
|
-
export type FindingLabels = Record<FindingSeverity, string>;
|
|
36
|
-
const SEV_COLOR: Record<FindingSeverity, ColorName> = {
|
|
37
|
-
critical: "red",
|
|
38
|
-
warning: "amber",
|
|
39
|
-
info: "blue",
|
|
40
|
-
positive: "emerald",
|
|
41
|
-
};
|
|
42
|
-
|
|
43
45
|
/**
|
|
44
|
-
* One ranked insight from an AI
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
46
|
+
* One ranked insight from an AI check — a cross-check discrepancy, an audit
|
|
47
|
+
* observation, a briefing item. Good defaults (severity word · title · detail
|
|
48
|
+
* · the PROMINENT metric · `Sources` chips) with a `children` slot for any
|
|
49
|
+
* extra composition. Display-only: a finding informs the verdict the host
|
|
50
|
+
* records; it decides nothing itself. Stack several most-severe first —
|
|
51
|
+
* inside a `ChangeReview` wrap each in a display-only `Change` (the family's
|
|
52
|
+
* dividers apply); standalone, separate them yourself.
|
|
50
53
|
*/
|
|
51
54
|
export function Finding(props: FindingProps) {
|
|
52
|
-
const
|
|
53
|
-
const words = { ...useLoticsLocale().finding, ...props.labels };
|
|
54
|
-
const sev = { word: words[severity], color: SEV_COLOR[severity] };
|
|
55
|
+
const words = useLoticsLocale().finding;
|
|
55
56
|
return (
|
|
56
|
-
<View style={styles.
|
|
57
|
-
<
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
</Text>
|
|
65
|
-
{props.metricCaption ? (
|
|
66
|
-
<Text size="xs" color="muted">
|
|
67
|
-
{props.metricCaption}
|
|
68
|
-
</Text>
|
|
69
|
-
) : null}
|
|
70
|
-
</View>
|
|
71
|
-
) : null}
|
|
72
|
-
</View>
|
|
73
|
-
|
|
74
|
-
<View style={styles.body}>
|
|
75
|
-
<Text size="md" weight="semibold" numberOfLines={2}>
|
|
76
|
-
{props.title}
|
|
57
|
+
<View style={[styles.root, props.style]}>
|
|
58
|
+
<Badge variant="dot" color={SEVERITY_COLOR[props.severity]} label={words[props.severity]} />
|
|
59
|
+
<Text size="sm" weight="medium">
|
|
60
|
+
{props.title}
|
|
61
|
+
</Text>
|
|
62
|
+
{props.detail ? (
|
|
63
|
+
<Text size="sm" color="muted">
|
|
64
|
+
{props.detail}
|
|
77
65
|
</Text>
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
66
|
+
) : null}
|
|
67
|
+
{props.metric ? (
|
|
68
|
+
<View style={styles.metricBlock}>
|
|
69
|
+
<Text size="lg" weight="semibold" tabular>
|
|
70
|
+
{props.metric}
|
|
81
71
|
</Text>
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
72
|
+
{props.metricCaption ? (
|
|
73
|
+
<Text size="xs" color="muted">
|
|
74
|
+
{props.metricCaption}
|
|
75
|
+
</Text>
|
|
76
|
+
) : null}
|
|
77
|
+
</View>
|
|
78
|
+
) : null}
|
|
79
|
+
{props.children}
|
|
85
80
|
{props.sources && props.sources.length > 0 ? (
|
|
86
|
-
<Sources sources={props.sources} onOpen={props.onOpenSource} />
|
|
81
|
+
<Sources label={null} sources={props.sources} onOpen={props.onOpenSource} />
|
|
87
82
|
) : null}
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
83
|
+
</View>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface FindingComparisonProps {
|
|
88
|
+
/** The disagreeing sides — each value with where it came from
|
|
89
|
+
* ("invoice.pdf" · "480 pcs"). Two or more. */
|
|
90
|
+
values: { label: string; value: string }[];
|
|
91
|
+
/** The computed difference, EMPHASIZED ("−40 pcs"). */
|
|
92
|
+
delta?: string;
|
|
93
|
+
/** Label for the delta row. Defaults to the localized "Difference". */
|
|
94
|
+
deltaLabel?: string;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The expected-vs-actual body of a `Finding` — each side a labeled row
|
|
99
|
+
* (source · value), a hairline, then the DELTA emphasized. Compose it as the
|
|
100
|
+
* finding's children; it replaces ad-hoc metric text for any
|
|
101
|
+
* one-value-disagrees insight (quantities, totals, dates).
|
|
102
|
+
*/
|
|
103
|
+
export function FindingComparison(props: FindingComparisonProps) {
|
|
104
|
+
const words = useLoticsLocale().finding;
|
|
105
|
+
return (
|
|
106
|
+
<View style={styles.comparison}>
|
|
107
|
+
{props.values.map((v, i) => (
|
|
108
|
+
<View key={`${v.label}-${i}`} style={styles.comparisonRow}>
|
|
109
|
+
<Text size="sm" color="muted" style={styles.comparisonLabel} numberOfLines={1}>
|
|
110
|
+
{v.label}
|
|
111
|
+
</Text>
|
|
112
|
+
<Text size="sm" weight="semibold" tabular numberOfLines={1} style={{ flexShrink: 1 }}>
|
|
113
|
+
{v.value}
|
|
114
|
+
</Text>
|
|
91
115
|
</View>
|
|
116
|
+
))}
|
|
117
|
+
{props.delta ? (
|
|
118
|
+
<>
|
|
119
|
+
<View style={styles.comparisonRule} />
|
|
120
|
+
<View style={styles.comparisonRow}>
|
|
121
|
+
<Text size="sm" color="muted" style={styles.comparisonLabel} numberOfLines={1}>
|
|
122
|
+
{props.deltaLabel ?? words.difference}
|
|
123
|
+
</Text>
|
|
124
|
+
<Text size="lg" weight="semibold" tabular>
|
|
125
|
+
{props.delta}
|
|
126
|
+
</Text>
|
|
127
|
+
</View>
|
|
128
|
+
</>
|
|
92
129
|
) : null}
|
|
93
130
|
</View>
|
|
94
131
|
);
|
|
95
132
|
}
|
|
96
133
|
|
|
97
134
|
const styles = StyleSheet.create({
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
gap: 12,
|
|
105
|
-
},
|
|
106
|
-
header: { flexDirection: "row", alignItems: "center", gap: 12 },
|
|
107
|
-
metric: { flexDirection: "row", alignItems: "baseline", gap: 5 },
|
|
108
|
-
body: { gap: 6 },
|
|
109
|
-
actionRow: { flexDirection: "row", justifyContent: "flex-end" },
|
|
135
|
+
root: { gap: 6, alignItems: "flex-start" },
|
|
136
|
+
metricBlock: { gap: 1, paddingVertical: 2 },
|
|
137
|
+
comparison: { gap: 6, paddingVertical: 4, alignSelf: "stretch", maxWidth: 420 },
|
|
138
|
+
comparisonRow: { flexDirection: "row", alignItems: "baseline", gap: 12 },
|
|
139
|
+
comparisonLabel: { width: 130 },
|
|
140
|
+
comparisonRule: { height: 1, backgroundColor: colors.zinc[100] },
|
|
110
141
|
});
|
|
142
|
+
|
|
@@ -31,7 +31,7 @@ export function FloatingActionBar(props: FloatingActionBarProps) {
|
|
|
31
31
|
<View pointerEvents="box-none" style={styles.wrap}>
|
|
32
32
|
<Card style={styles.bar}>
|
|
33
33
|
<Text size="sm" weight="semibold" tabular>{`${count} ${label}`}</Text>
|
|
34
|
-
<Button title={clearLabel} color="
|
|
34
|
+
<Button title={clearLabel} color="muted" onPress={onClear} />
|
|
35
35
|
{children}
|
|
36
36
|
</Card>
|
|
37
37
|
</View>
|
package/src/locale.tsx
CHANGED
|
@@ -2,10 +2,10 @@ import { createContext, useContext, type ReactNode } from "react";
|
|
|
2
2
|
import { type PaginationLabels } from "./pagination";
|
|
3
3
|
import { type SortHeaderLabels } from "./sort_header";
|
|
4
4
|
import { type ConfidenceLabels } from "./confidence";
|
|
5
|
-
import { type FindingLabels } from "./finding";
|
|
6
5
|
import { type RemainderMeterLabels } from "./remainder_meter";
|
|
7
6
|
import { type DateRangeFilterFieldLabels } from "./date_range_filter_field";
|
|
8
7
|
import { type GalleryLabels } from "./file_preview_types";
|
|
8
|
+
import { type FindingLabels } from "./finding";
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* The kit's localizable strings, one slice per string-bearing component. A
|
|
@@ -39,24 +39,26 @@ export interface LoticsLocale {
|
|
|
39
39
|
/** `Confidence`: the full level phrase ("High confidence" …). */
|
|
40
40
|
confidence: ConfidenceLabels;
|
|
41
41
|
/** `Finding`: the severity badge word ("Critical" …). */
|
|
42
|
-
finding: FindingLabels;
|
|
43
42
|
/** `RemainderMeter`: the applied / remaining / over / exact captions. */
|
|
44
43
|
remainderMeter: Required<RemainderMeterLabels>;
|
|
45
44
|
/** `ChangeReview` (+ `ChangeReviewActions`): the review-card chrome — the
|
|
46
45
|
* Keep/Drop verdicts, Undo, the Applied/Discarded tags, the kept-counter,
|
|
47
46
|
* the title, and the commit-bar Accept-all / Discard. */
|
|
47
|
+
finding: FindingLabels;
|
|
48
48
|
changeReview: {
|
|
49
49
|
title: string;
|
|
50
50
|
accept: string;
|
|
51
51
|
reject: string;
|
|
52
52
|
undo: string;
|
|
53
|
-
applied: string;
|
|
54
|
-
discarded: string;
|
|
55
53
|
keptCount: (kept: number, total: number) => string;
|
|
56
54
|
acceptAll: string;
|
|
57
55
|
discard: string;
|
|
58
56
|
apply: string;
|
|
59
|
-
|
|
57
|
+
recommended: string;
|
|
58
|
+
customValue: string;
|
|
59
|
+
opAdd: string;
|
|
60
|
+
opEdit: string;
|
|
61
|
+
opRemove: string;
|
|
60
62
|
};
|
|
61
63
|
/** `DateRangeFilterField` (and the `DateFilter` panel it wraps): presets,
|
|
62
64
|
* from/to, the footer Clear/Done, the trigger placeholder, and the time-field
|
|
@@ -89,25 +91,27 @@ export const en: LoticsLocale = {
|
|
|
89
91
|
formField: { optional: "Optional" },
|
|
90
92
|
drawer: { previous: "Previous record", next: "Next record", close: "Close" },
|
|
91
93
|
confidence: { high: "High confidence", medium: "Medium confidence", low: "Low confidence" },
|
|
92
|
-
finding: { critical: "Critical", warning: "Warning", info: "Note", positive: "On track" },
|
|
93
94
|
remainderMeter: {
|
|
94
95
|
applied: (allocated, total) => `${allocated} of ${total} applied`,
|
|
95
96
|
remaining: (remainder) => `${remainder} unapplied`,
|
|
96
97
|
over: (amount) => `Over by ${amount}`,
|
|
97
98
|
exact: "Fully applied",
|
|
98
99
|
},
|
|
100
|
+
finding: { critical: "Critical", warning: "Warning", info: "Note", positive: "On track", difference: "Difference" },
|
|
99
101
|
changeReview: {
|
|
100
102
|
title: "Suggested edits",
|
|
101
103
|
accept: "Keep",
|
|
102
104
|
reject: "Drop",
|
|
103
105
|
undo: "Undo",
|
|
104
|
-
applied: "Applied",
|
|
105
|
-
discarded: "Discarded",
|
|
106
106
|
keptCount: (kept, total) => `${kept} of ${total} kept`,
|
|
107
|
-
acceptAll: "
|
|
107
|
+
acceptAll: "Keep all",
|
|
108
108
|
discard: "Discard",
|
|
109
109
|
apply: "Apply",
|
|
110
|
-
|
|
110
|
+
recommended: "AI pick",
|
|
111
|
+
customValue: "Type another value",
|
|
112
|
+
opAdd: "Add",
|
|
113
|
+
opEdit: "Edit",
|
|
114
|
+
opRemove: "Delete",
|
|
111
115
|
},
|
|
112
116
|
dateRange: {
|
|
113
117
|
year: "Year", month: "Month", day: "Day", hour: "Hour", minute: "Minute", dayPeriod: "AM/PM",
|
|
@@ -158,25 +162,27 @@ export const vi: LoticsLocale = {
|
|
|
158
162
|
formField: { optional: "Tùy chọn" },
|
|
159
163
|
drawer: { previous: "Bản ghi trước", next: "Bản ghi sau", close: "Đóng" },
|
|
160
164
|
confidence: { high: "Độ tin cậy cao", medium: "Độ tin cậy trung bình", low: "Độ tin cậy thấp" },
|
|
161
|
-
finding: { critical: "Nghiêm trọng", warning: "Cảnh báo", info: "Ghi chú", positive: "Đúng tiến độ" },
|
|
162
165
|
remainderMeter: {
|
|
163
166
|
applied: (allocated, total) => `Đã phân bổ ${allocated}/${total}`,
|
|
164
167
|
remaining: (remainder) => `Còn ${remainder}`,
|
|
165
168
|
over: (amount) => `Vượt ${amount}`,
|
|
166
169
|
exact: "Đã phân bổ đủ",
|
|
167
170
|
},
|
|
171
|
+
finding: { critical: "Nghiêm trọng", warning: "Cảnh báo", info: "Ghi chú", positive: "Đúng tiến độ", difference: "Chênh lệch" },
|
|
168
172
|
changeReview: {
|
|
169
173
|
title: "Đề xuất chỉnh sửa",
|
|
170
174
|
accept: "Giữ",
|
|
171
175
|
reject: "Bỏ",
|
|
172
176
|
undo: "Hoàn tác",
|
|
173
|
-
|
|
174
|
-
discarded: "Đã bỏ",
|
|
175
|
-
keptCount: (kept, total) => `Giữ ${kept}/${total}`,
|
|
177
|
+
keptCount: (kept, total) => `Đã giữ ${kept}/${total}`,
|
|
176
178
|
acceptAll: "Giữ tất cả",
|
|
177
|
-
discard: "
|
|
179
|
+
discard: "Bỏ hết",
|
|
178
180
|
apply: "Áp dụng",
|
|
179
|
-
|
|
181
|
+
recommended: "AI đề xuất",
|
|
182
|
+
customValue: "Nhập giá trị khác",
|
|
183
|
+
opAdd: "Thêm",
|
|
184
|
+
opEdit: "Sửa",
|
|
185
|
+
opRemove: "Xóa",
|
|
180
186
|
},
|
|
181
187
|
dateRange: {
|
|
182
188
|
year: "Năm", month: "Tháng", day: "Ngày", hour: "Giờ", minute: "Phút", dayPeriod: "SA/CH",
|
package/src/sources.tsx
CHANGED
|
@@ -16,8 +16,9 @@ export interface SourceRef {
|
|
|
16
16
|
|
|
17
17
|
export interface SourcesProps {
|
|
18
18
|
sources: SourceRef[];
|
|
19
|
-
/** Eyebrow above the chips. Default "Sources"
|
|
20
|
-
|
|
19
|
+
/** Eyebrow above the chips. Default "Sources"; pass `null` for chips only —
|
|
20
|
+
* the form used INSIDE a `Change` (a review card's own header/body). */
|
|
21
|
+
label?: string | null;
|
|
21
22
|
/** Pass to make each chip openable (jump to the record / document / page). The
|
|
22
23
|
* chips then show a hover state + an open glyph; the host does the navigation. */
|
|
23
24
|
onOpen?: (source: SourceRef) => void;
|
|
@@ -45,9 +46,11 @@ export function Sources(props: SourcesProps) {
|
|
|
45
46
|
if (props.sources.length === 0) return null;
|
|
46
47
|
return (
|
|
47
48
|
<View style={{ gap: 8 }}>
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
49
|
+
{props.label === null ? null : (
|
|
50
|
+
<Text size="xs" color="muted" weight="medium">
|
|
51
|
+
{props.label ?? "Sources"}
|
|
52
|
+
</Text>
|
|
53
|
+
)}
|
|
51
54
|
<View style={styles.row}>
|
|
52
55
|
{props.sources.map((s) =>
|
|
53
56
|
props.onOpen ? (
|
|
@@ -1,174 +0,0 @@
|
|
|
1
|
-
import { useRef, useState } from "react";
|
|
2
|
-
import { ScrollView, View } from "react-native";
|
|
3
|
-
import { colors } from "@lotics/ui/colors";
|
|
4
|
-
import { Text } from "@lotics/ui/text";
|
|
5
|
-
import { DotsIndicator } from "@lotics/ui/dots_indicator";
|
|
6
|
-
import { Composer } from "@lotics/ui/composer";
|
|
7
|
-
import { ChangeReview, ChangeReviewActions, ChangeDiff, type ChangeReviewItemStatus } from "@lotics/ui/change_review";
|
|
8
|
-
|
|
9
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
-
// Template · AI assistant — a CHAT workflow, a different shape from the design
|
|
11
|
-
// canvas: a conversation where the agent's answer carries SUGGESTED EDITS the
|
|
12
|
-
// human approves or rejects ONE BY ONE (a before→after diff per change, ✓/✕),
|
|
13
|
-
// then applies only the accepted set. The review-each-edit pattern — an AI
|
|
14
|
-
// copilot proposing changes to a record, never committing on its own. All mock.
|
|
15
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
16
|
-
|
|
17
|
-
/** One field's before→after edit — the assistant's proposals, reviewed 1-by-1. */
|
|
18
|
-
interface EditChange {
|
|
19
|
-
label: string;
|
|
20
|
-
before?: string;
|
|
21
|
-
after: string;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
interface ChatMsg {
|
|
25
|
-
id: string;
|
|
26
|
-
role: "user" | "agent";
|
|
27
|
-
text?: string;
|
|
28
|
-
/** An agent turn may carry suggested edits to review 1-by-1. */
|
|
29
|
-
changes?: EditChange[];
|
|
30
|
-
applied?: boolean;
|
|
31
|
-
typing?: boolean;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const SUGGESTED: EditChange[] = [
|
|
35
|
-
{ label: "Company", before: "acme packaging co", after: "Acme Packaging Co." },
|
|
36
|
-
{ label: "Phone", before: "0901234567", after: "+84 90 123 4567" },
|
|
37
|
-
{ label: "Email", before: "SALES@ACME.VN", after: "sales@acme.vn" },
|
|
38
|
-
{ label: "Industry", after: "Packaging" },
|
|
39
|
-
];
|
|
40
|
-
|
|
41
|
-
export function TplAssistant() {
|
|
42
|
-
const idRef = useRef(1);
|
|
43
|
-
const nextId = () => `m${idRef.current++}`;
|
|
44
|
-
const [messages, setMessages] = useState<ChatMsg[]>([
|
|
45
|
-
{ id: "m0", role: "agent", text: "Paste a record or ask me to tidy one up — I'll propose changes you can approve one by one." },
|
|
46
|
-
]);
|
|
47
|
-
const [decisions, setDecisions] = useState<Record<string, ChangeReviewItemStatus>>({});
|
|
48
|
-
const [prompt, setPrompt] = useState("");
|
|
49
|
-
const [busy, setBusy] = useState(false);
|
|
50
|
-
const [proposed, setProposed] = useState(false);
|
|
51
|
-
|
|
52
|
-
const send = (text: string) => {
|
|
53
|
-
setPrompt("");
|
|
54
|
-
const userMsg: ChatMsg = { id: nextId(), role: "user", text };
|
|
55
|
-
const typingId = nextId();
|
|
56
|
-
setMessages((m) => [...m, userMsg, { id: typingId, role: "agent", typing: true }]);
|
|
57
|
-
setBusy(true);
|
|
58
|
-
setTimeout(() => {
|
|
59
|
-
setBusy(false);
|
|
60
|
-
setMessages((m) =>
|
|
61
|
-
m.map((msg) =>
|
|
62
|
-
msg.id === typingId
|
|
63
|
-
? proposed
|
|
64
|
-
? { id: msg.id, role: "agent", text: "Done — anything else to adjust?" }
|
|
65
|
-
: { id: msg.id, role: "agent", text: "Here's what I'd standardize on this contact. Review each:", changes: SUGGESTED.map((c) => ({ ...c, status: "pending" })) }
|
|
66
|
-
: msg,
|
|
67
|
-
),
|
|
68
|
-
);
|
|
69
|
-
setProposed(true);
|
|
70
|
-
}, 1300);
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const decide = (msgId: string, index: number, status: ChangeReviewItemStatus) => {
|
|
74
|
-
setDecisions((d) => ({ ...d, [`${msgId}:${index}`]: status }));
|
|
75
|
-
};
|
|
76
|
-
|
|
77
|
-
const applyMsg = (msgId: string) => {
|
|
78
|
-
const msg = messages.find((m) => m.id === msgId);
|
|
79
|
-
const n = (msg?.changes ?? []).filter((_, i) => decisions[`${msgId}:${i}`] === "accepted").length;
|
|
80
|
-
setMessages((m) => [
|
|
81
|
-
...m.map((x) => (x.id === msgId ? { ...x, applied: true } : x)),
|
|
82
|
-
{ id: nextId(), role: "agent", text: `Applied ${n} change${n === 1 ? "" : "s"} to the contact.` },
|
|
83
|
-
]);
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
return (
|
|
87
|
-
<View style={{ flex: 1, backgroundColor: colors.white }}>
|
|
88
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingHorizontal: 20, paddingVertical: 12, borderBottomWidth: 1, borderBottomColor: colors.zinc[200], backgroundColor: colors.background }}>
|
|
89
|
-
<View style={{ width: 24, height: 24, borderRadius: 7, alignItems: "center", justifyContent: "center", backgroundColor: colors.zinc[100] }}>
|
|
90
|
-
<View style={{ width: 4, height: 4, borderRadius: 2, backgroundColor: colors.zinc[900] }} />
|
|
91
|
-
</View>
|
|
92
|
-
<Text size="sm" weight="semibold">Record assistant</Text>
|
|
93
|
-
</View>
|
|
94
|
-
|
|
95
|
-
<ScrollView contentContainerStyle={{ padding: 20, paddingBottom: 140, gap: 16, maxWidth: 760, width: "100%", alignSelf: "center" }}>
|
|
96
|
-
{messages.map((m) => {
|
|
97
|
-
if (m.role === "user") {
|
|
98
|
-
return (
|
|
99
|
-
<View key={m.id} style={{ alignItems: "flex-end" }}>
|
|
100
|
-
<View style={{ maxWidth: "82%", backgroundColor: colors.zinc[900], borderRadius: 14, paddingHorizontal: 14, paddingVertical: 10 }}>
|
|
101
|
-
<Text size="sm" color="inverted">{m.text}</Text>
|
|
102
|
-
</View>
|
|
103
|
-
</View>
|
|
104
|
-
);
|
|
105
|
-
}
|
|
106
|
-
const active = m.changes != null && !m.applied;
|
|
107
|
-
return (
|
|
108
|
-
<View key={m.id} style={{ flexDirection: "row", gap: 10, alignItems: "flex-start" }}>
|
|
109
|
-
<View style={{ width: 26, height: 26, borderRadius: 8, alignItems: "center", justifyContent: "center", backgroundColor: colors.zinc[100], marginTop: 1 }}>
|
|
110
|
-
<View style={{ width: 4, height: 4, borderRadius: 2, backgroundColor: colors.zinc[900] }} />
|
|
111
|
-
</View>
|
|
112
|
-
<View style={{ flex: 1, gap: 10 }}>
|
|
113
|
-
{m.typing ? (
|
|
114
|
-
<View style={{ paddingVertical: 6 }}><DotsIndicator size={6} color={colors.zinc[400]} /></View>
|
|
115
|
-
) : null}
|
|
116
|
-
{m.text ? <Text size="sm">{m.text}</Text> : null}
|
|
117
|
-
{m.changes ? (
|
|
118
|
-
<View style={{ gap: 14 }}>
|
|
119
|
-
<ChangeReview
|
|
120
|
-
items={m.changes}
|
|
121
|
-
getKey={(c, i) => `${m.id}:${i}`}
|
|
122
|
-
statusOf={(_, i) => decisions[`${m.id}:${i}`] ?? "pending"}
|
|
123
|
-
renderItem={(c) => (
|
|
124
|
-
<>
|
|
125
|
-
<Text size="xs" color="muted" weight="medium">{c.label}</Text>
|
|
126
|
-
<ChangeDiff before={c.before} after={c.after} />
|
|
127
|
-
</>
|
|
128
|
-
)}
|
|
129
|
-
renderSummary={(c, kept) => (
|
|
130
|
-
<View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
|
|
131
|
-
<Text size="sm" weight="medium" numberOfLines={1}>{c.label}</Text>
|
|
132
|
-
<Text size="sm" numberOfLines={1} color={kept ? "default" : "muted"} style={[{ flex: 1 }, kept ? undefined : { textDecorationLine: "line-through" }]}>{kept ? c.after : (c.before ?? c.after)}</Text>
|
|
133
|
-
</View>
|
|
134
|
-
)}
|
|
135
|
-
status={m.applied ? "applied" : "open"}
|
|
136
|
-
onAcceptItem={active ? (i) => decide(m.id, i, "accepted") : undefined}
|
|
137
|
-
onRejectItem={active ? (i) => decide(m.id, i, "rejected") : undefined}
|
|
138
|
-
onUndoItem={active ? (i) => setDecisions((d) => { const n = { ...d }; delete n[`${m.id}:${i}`]; return n; }) : undefined}
|
|
139
|
-
/>
|
|
140
|
-
{/* Inline (no dialog) — the commit bar sits under the list behind a divider. */}
|
|
141
|
-
{active && !m.applied ? (
|
|
142
|
-
<View style={{ borderTopWidth: 1, borderTopColor: colors.zinc[200], paddingTop: 14, flexDirection: "row" }}>
|
|
143
|
-
<ChangeReviewActions
|
|
144
|
-
items={m.changes}
|
|
145
|
-
statusOf={(_, i) => decisions[`${m.id}:${i}`] ?? "pending"}
|
|
146
|
-
applyLabel="Apply accepted"
|
|
147
|
-
onAcceptItem={(i) => decide(m.id, i, "accepted")}
|
|
148
|
-
onApply={() => applyMsg(m.id)}
|
|
149
|
-
onDiscard={() => setMessages((ms) => ms.map((x) => (x.id === m.id ? { ...x, applied: true } : x)))}
|
|
150
|
-
/>
|
|
151
|
-
</View>
|
|
152
|
-
) : null}
|
|
153
|
-
</View>
|
|
154
|
-
) : null}
|
|
155
|
-
</View>
|
|
156
|
-
</View>
|
|
157
|
-
);
|
|
158
|
-
})}
|
|
159
|
-
</ScrollView>
|
|
160
|
-
|
|
161
|
-
<View style={{ position: "absolute", left: 0, right: 0, bottom: 20, alignItems: "center", paddingHorizontal: 16 }}>
|
|
162
|
-
<View style={{ width: "100%", maxWidth: 720 }}>
|
|
163
|
-
<Composer
|
|
164
|
-
value={prompt}
|
|
165
|
-
onChangeText={setPrompt}
|
|
166
|
-
onSend={send}
|
|
167
|
-
disabled={busy}
|
|
168
|
-
placeholder={proposed ? "Ask for another change…" : "Try: “Tidy up this contact”"}
|
|
169
|
-
/>
|
|
170
|
-
</View>
|
|
171
|
-
</View>
|
|
172
|
-
</View>
|
|
173
|
-
);
|
|
174
|
-
}
|