@lotics/ui 4.3.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.
@@ -3,60 +3,67 @@ import { ScrollView, View } from "react-native";
3
3
  import { colors } from "@lotics/ui/colors";
4
4
  import { Text } from "@lotics/ui/text";
5
5
  import { Button } from "@lotics/ui/button";
6
- import { LinkButton } from "@lotics/ui/link_button";
7
- import { Card, CardBody, CardFooter, CardHeader, CardHeaderTitle, CardHeaderMeta } from "@lotics/ui/card";
6
+ import { Card, CardBody, CardHeader, CardHeaderTitle } from "@lotics/ui/card";
8
7
  import { FileDropzone } from "@lotics/ui/file_dropzone";
9
8
  import { AgentRun, type AgentRunStep } from "@lotics/ui/agent_run";
10
- import { AssistedField } from "@lotics/ui/assisted_field";
11
- import { InlineTextInput } from "@lotics/ui/inline_text_input";
9
+ import { RecordReview, type RecordField } from "@lotics/ui/record_review";
12
10
  import { Sources } from "@lotics/ui/sources";
13
11
  import { CompletionState } from "@lotics/ui/completion_state";
14
12
  import { type ConfidenceLevel } from "@lotics/ui/confidence";
15
13
 
16
14
  // ─────────────────────────────────────────────────────────────────────────────
17
- // Template · AI extract & confirm — a DIFFERENT AI workflow from the design
18
- // assistant: a one-shot data-capture pass, no iteration. Drop a document → the
19
- // agent extracts structured fields (AgentRun) → every field lands as a tinted
20
- // ESTIMATE the human confirms or overrides (AssistedField) submit once all
21
- // stand. The provenance is visible the whole way: nothing is committed until a
22
- // person stood behind each value. All mock.
15
+ // Template · AI extract & confirm — read an image, then review each extracted
16
+ // RECORD before it's added to a table. Drop a photo → the agent extracts the
17
+ // lines (AgentRun) → each lands as a `RecordReview` card whose fields are
18
+ // click-to-edit; Confirm collapses it to a tidy checklist row (Undo to re-open),
19
+ // Remove skips it Save all writes the confirmed lines at once. A one-shot
20
+ // capture pass: nothing is stored until the human has reviewed it. All mock.
23
21
  // ─────────────────────────────────────────────────────────────────────────────
24
22
 
25
23
  type ScriptStep = Omit<AgentRunStep, "status">;
26
24
  type Phase = "intake" | "extracting" | "review" | "done";
25
+ type Status = "pending" | "confirmed" | "removed";
27
26
 
28
27
  const EXTRACT: ScriptStep[] = [
29
- { id: "x1", label: "Reading the document", detail: "A 2-page supplier invoice (PDF)." },
30
- { id: "x2", label: "extract_fields", kind: "tool" },
31
- { id: "x3", label: "Matching the vendor", detail: "Acme Packaging Co. found in your contacts." },
32
- { id: "x4", label: "Reading the totals", detail: "Subtotal, VAT 8%, grand total." },
28
+ { id: "x1", label: "Reading the packing list", detail: "A photo of a 3-line shipping manifest." },
29
+ { id: "x2", label: "detect_table", kind: "tool" },
30
+ { id: "x3", label: "Extracting the line items", detail: "Description, HS code, quantity, weight and value per carton." },
31
+ { id: "x4", label: "classify_hs_codes", kind: "tool" },
33
32
  ];
34
33
 
35
- interface Field {
36
- key: string;
37
- label: string;
38
- value: string;
39
- unit?: string;
40
- conf: ConfidenceLevel;
34
+ interface RecordData {
35
+ id: string;
36
+ title: string;
37
+ confidence: ConfidenceLevel;
38
+ fields: RecordField[];
41
39
  }
42
- const FIELDS: Field[] = [
43
- { key: "vendor", label: "Vendor", value: "Acme Packaging Co.", conf: "high" },
44
- { key: "invoice", label: "Invoice no.", value: "INV-2026-0412", conf: "high" },
45
- { key: "date", label: "Invoice date", value: "2026-06-10", conf: "medium" },
46
- { key: "taxid", label: "Tax ID (MST)", value: "0312345678", conf: "medium" },
47
- { key: "total", label: "Total", value: "48,200,000", unit: "₫", conf: "high" },
40
+ const RECORDS: RecordData[] = [
41
+ { id: "r1", title: "Carton 1 Frozen shrimp", confidence: "high", fields: [
42
+ { label: "HS code", value: "0306.17.10" },
43
+ { label: "Quantity", value: "120", unit: "ctn" },
44
+ { label: "Net weight", value: "1,200", unit: "kg" },
45
+ { label: "Value (FOB)", value: "86,200,000", unit: "₫" },
46
+ ] },
47
+ { id: "r2", title: "Carton 2 — Cotton T-shirts", confidence: "medium", fields: [
48
+ { label: "HS code", value: "6109.10.00", uncertain: true },
49
+ { label: "Quantity", value: "500", unit: "pcs" },
50
+ { label: "Net weight", value: "340", unit: "kg" },
51
+ { label: "Value (FOB)", value: "41,300,000", unit: "₫" },
52
+ ] },
53
+ { id: "r3", title: "Carton 3 — Li-ion batteries", confidence: "low", fields: [
54
+ { label: "HS code", value: "8507.60.10", uncertain: true },
55
+ { label: "Quantity", value: "48", unit: "pcs" },
56
+ { label: "Net weight", value: "96", unit: "kg" },
57
+ { label: "Value (FOB)", value: "18,700,000", unit: "₫" },
58
+ ] },
48
59
  ];
49
60
 
50
- const initialValues = () => Object.fromEntries(FIELDS.map((f) => [f.key, f.value]));
61
+ const initialValues = () => Object.fromEntries(RECORDS.map((r) => [r.id, r.fields.map((f) => f.value)]));
51
62
 
52
63
  export function TplExtract() {
53
64
  const [phase, setPhase] = useState<Phase>("intake");
54
- const [confirmed, setConfirmed] = useState<Record<string, boolean>>({});
55
- // The agent's values are estimates the human can override. `editing` is the
56
- // field whose AssistedField has swapped to an inline input (the `onEdit`
57
- // contract); `values` holds the live value, overridden on save.
58
- const [values, setValues] = useState<Record<string, string>>(initialValues);
59
- const [editing, setEditing] = useState<string | null>(null);
65
+ const [statuses, setStatuses] = useState<Record<string, Status>>({});
66
+ const [values, setValues] = useState<Record<string, string[]>>(initialValues);
60
67
 
61
68
  const [revealed, setRevealed] = useState(0);
62
69
  const [streaming, setStreaming] = useState(false);
@@ -86,96 +93,88 @@ export function TplExtract() {
86
93
  const reset = () => {
87
94
  setStreaming(false);
88
95
  setRevealed(0);
89
- setConfirmed({});
96
+ setStatuses({});
90
97
  setValues(initialValues());
91
- setEditing(null);
92
98
  setPhase("intake");
93
99
  };
94
100
 
95
- const allConfirmed = FIELDS.every((f) => confirmed[f.key]);
101
+ const setStatus = (id: string, status: Status | undefined) =>
102
+ setStatuses((s) => {
103
+ const n = { ...s };
104
+ if (status) n[id] = status;
105
+ else delete n[id];
106
+ return n;
107
+ });
108
+ const editField = (id: string, index: number, value: string) =>
109
+ setValues((v) => ({ ...v, [id]: v[id].map((x, i) => (i === index ? value : x)) }));
110
+
111
+ const confirmedCount = RECORDS.filter((r) => statuses[r.id] === "confirmed").length;
96
112
 
97
113
  return (
98
114
  <ScrollView style={{ backgroundColor: colors.zinc[50] }} contentContainerStyle={{ padding: 28, paddingBottom: 120 }}>
99
115
  <View style={{ maxWidth: 720, width: "100%", alignSelf: "center", gap: 16 }}>
100
116
  <View style={{ flexDirection: "row", alignItems: "flex-start", gap: 16 }}>
101
117
  <View style={{ flex: 1, gap: 2 }}>
102
- <Text size="xl" weight="semibold">Capture invoice</Text>
103
- <Text size="sm" color="muted">Drop a document; the agent extracts the fields, you confirm each before saving.</Text>
118
+ <Text size="xl" weight="semibold">Capture packing list</Text>
119
+ <Text size="sm" color="muted">Drop a photo; the agent extracts each line, you review and edit, then save them all to the table.</Text>
104
120
  </View>
105
- {phase !== "intake" ? <Button title="Start over" color="muted" shape="rounded" icon="rotate-ccw" onPress={reset} /> : null}
121
+ {phase !== "intake" ? <Button title="Start over" color="muted" shape="rounded" onPress={reset} /> : null}
106
122
  </View>
107
123
 
108
124
  {phase === "intake" ? (
109
125
  <Card style={{ padding: 0 }}>
110
- <CardHeader><CardHeaderTitle info="Extraction is an estimate every field is confirmable before anything is stored.">Source document</CardHeaderTitle></CardHeader>
126
+ <CardHeader><CardHeaderTitle info="Each extracted line is reviewable and editable before anything is saved.">Source photo</CardHeaderTitle></CardHeader>
111
127
  <CardBody>
112
- <FileDropzone onFiles={start} accept="application/pdf,image/*" height={200} label="Drop an invoice" hint="or click to choose · PDF, JPG, PNG" dropLabel="Drop to extract" />
128
+ <FileDropzone onFiles={start} accept="image/*,application/pdf" height={200} label="Drop a packing list" hint="or click to choose · JPG, PNG, PDF" dropLabel="Drop to extract" />
113
129
  </CardBody>
114
130
  </Card>
115
131
  ) : null}
116
132
 
117
133
  {phase === "extracting" ? (
118
134
  <Card>
119
- <AgentRun title="Extracting fields" steps={steps} state={streaming ? "streaming" : "done"} />
135
+ <AgentRun title="Extracting line items" steps={steps} state={streaming ? "streaming" : "done"} />
120
136
  </Card>
121
137
  ) : null}
122
138
 
123
139
  {phase === "review" ? (
124
- <Card style={{ padding: 0 }}>
125
- <CardHeader>
126
- <CardHeaderTitle info="Each value is an AI estimate until you confirm it. Override any that read wrong.">Extracted fields</CardHeaderTitle>
127
- <CardHeaderMeta>{`${Object.values(confirmed).filter(Boolean).length}/${FIELDS.length}`}</CardHeaderMeta>
128
- </CardHeader>
129
- <CardBody>
130
- {FIELDS.map((f) =>
131
- editing === f.key ? (
132
- <View key={f.key} style={{ flexDirection: "row", alignItems: "center", gap: 16, paddingVertical: 8 }}>
133
- <Text size="sm" color="muted" style={{ width: 132 }}>{f.label}</Text>
134
- <View style={{ flex: 1 }}>
135
- <InlineTextInput
136
- value={values[f.key]}
137
- accessibilityLabel={`Edit ${f.label}`}
138
- onSave={(next) => {
139
- // Override the AI estimate; a value the human typed is
140
- // confirmed by definition.
141
- setValues((v) => ({ ...v, [f.key]: next }));
142
- setConfirmed((c) => ({ ...c, [f.key]: true }));
143
- setEditing(null);
144
- }}
145
- />
146
- </View>
147
- </View>
148
- ) : (
149
- <AssistedField
150
- key={f.key}
151
- label={f.label}
152
- value={values[f.key]}
153
- unit={f.unit}
154
- estimated={!confirmed[f.key]}
155
- confidence={f.conf}
156
- onConfirm={() => setConfirmed((c) => ({ ...c, [f.key]: true }))}
157
- onEdit={() => setEditing(f.key)}
158
- />
159
- ),
160
- )}
161
- <Sources
162
- label="Extracted from"
163
- onOpen={() => {}}
164
- sources={[{ id: "doc", label: "invoice-0412.pdf", kind: "document", detail: "2 pages" }]}
140
+ <>
141
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
142
+ <Text size="sm" weight="semibold" style={{ flex: 1 }}>Extracted lines</Text>
143
+ <Text size="xs" color="muted" tabular>{confirmedCount} of {RECORDS.length} confirmed</Text>
144
+ </View>
145
+ <Sources label="Extracted from" onOpen={() => {}} sources={[{ id: "doc", label: "packing-list.jpg", kind: "document", detail: "1 photo" }]} />
146
+ <View style={{ gap: 10 }}>
147
+ {RECORDS.map((r) => (
148
+ <RecordReview
149
+ key={r.id}
150
+ title={r.title}
151
+ confidence={r.confidence}
152
+ status={statuses[r.id] ?? "pending"}
153
+ fields={r.fields.map((f, i) => ({ ...f, value: values[r.id][i] }))}
154
+ onEditField={(i, value) => editField(r.id, i, value)}
155
+ onConfirm={() => setStatus(r.id, "confirmed")}
156
+ onRemove={() => setStatus(r.id, "removed")}
157
+ onUndo={() => setStatus(r.id, undefined)}
158
+ />
159
+ ))}
160
+ </View>
161
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
162
+ <Button title="Confirm all" color="muted" shape="rounded" onPress={() => setStatuses(Object.fromEntries(RECORDS.map((r) => [r.id, "confirmed" as Status])))} />
163
+ <View style={{ flex: 1 }} />
164
+ <Button
165
+ title={`Save ${confirmedCount} ${confirmedCount === 1 ? "line" : "lines"}`}
166
+ color="primary"
167
+ shape="rounded"
168
+ disabled={confirmedCount === 0}
169
+ onPress={() => setPhase("done")}
165
170
  />
166
- </CardBody>
167
- <CardFooter>
168
- <View style={{ flex: 1 }}>
169
- {!allConfirmed ? <LinkButton title="Confirm all" onPress={() => setConfirmed(Object.fromEntries(FIELDS.map((f) => [f.key, true])))} /> : null}
170
- </View>
171
- <Button title="Save invoice" color="primary" shape="rounded" disabled={!allConfirmed} onPress={() => setPhase("done")} />
172
- </CardFooter>
173
- </Card>
171
+ </View>
172
+ </>
174
173
  ) : null}
175
174
 
176
175
  {phase === "done" ? (
177
176
  <Card>
178
- <CompletionState title="Invoice saved" summary="All five fields confirmed and written to the ledger.">
177
+ <CompletionState title={`${confirmedCount} lines saved`} summary="The confirmed lines were written to the shipment table.">
179
178
  <Button title="Capture another" color="secondary" shape="rounded" onPress={reset} />
180
179
  </CompletionState>
181
180
  </Card>
@@ -0,0 +1,288 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { Text } from "@lotics/ui/text";
4
+ import { colors } from "@lotics/ui/colors";
5
+ import { Divider } from "@lotics/ui/divider";
6
+ import { Callout, CalloutText, CalloutTitle } from "@lotics/ui/callout";
7
+ import { PressableHighlight } from "@lotics/ui/pressable_highlight";
8
+ import { PromptField } from "@lotics/ui/prompt_field";
9
+ import { AgentRun, type AgentRunStep } from "@lotics/ui/agent_run";
10
+ import { SpecList, type SpecRow } from "@lotics/ui/spec_list";
11
+ import { Confidence, type ConfidenceLevel } from "@lotics/ui/confidence";
12
+ import { Sources, type SourceRef } from "@lotics/ui/sources";
13
+
14
+ // ─────────────────────────────────────────────────────────────────────────────
15
+ // Template · Answer desk — converse on the LEFT, a pinned structured ANSWER on
16
+ // the RIGHT. The AI's verdict isn't prose buried in a chat log: it's a panel of
17
+ // scannable components — a code, the exact duty breakdown (SpecList), the
18
+ // policies that apply, the sources it rested on. You ask, you watch the agent
19
+ // work (AgentRun), the panel pins; a follow-up refines the SAME panel. Same
20
+ // shape for any look-up-and-explain: tariff/HS, fee lookup, policy Q&A, a
21
+ // spec/compliance desk. The structured answer is the product; chat just steers.
22
+ // ─────────────────────────────────────────────────────────────────────────────
23
+
24
+ type Tone = "warning" | "info" | "success";
25
+ interface Policy { tone: Tone; title: string; text: string }
26
+ interface Verdict {
27
+ id: string;
28
+ query: string;
29
+ code: string;
30
+ heading: string;
31
+ confidence: ConfidenceLevel;
32
+ basis: string;
33
+ duty: SpecRow[];
34
+ dutyTotal: SpecRow;
35
+ specs: SpecRow[];
36
+ policies: Policy[];
37
+ sources: SourceRef[];
38
+ }
39
+
40
+ const VERDICTS: Verdict[] = [
41
+ {
42
+ id: "v-shrimp",
43
+ query: "Black tiger shrimp, headless shell-on, IQF frozen, 1 kg retail packs",
44
+ code: "0306.17.10",
45
+ heading: "Frozen shrimp & prawns of the family Penaeidae — not in airtight containers",
46
+ confidence: "high",
47
+ basis:
48
+ "Headless, shell-on, individually-quick-frozen Penaeus monodon → heading 0306.17 (frozen shrimp of the Penaeidae); national line .10 for consumer packs ≤ 1 kg.",
49
+ duty: [
50
+ { label: "Import duty (MFN)", value: "0%" },
51
+ { label: "VAT", value: "8%" },
52
+ { label: "Anti-dumping", value: "0%" },
53
+ ],
54
+ dutyTotal: { label: "Effective tax on CIF", value: "8%" },
55
+ specs: [
56
+ { label: "Chapter", value: "03 — Fish & crustaceans" },
57
+ { label: "Form", value: "Frozen (IQF)" },
58
+ { label: "Presentation", value: "Headless, shell-on" },
59
+ { label: "Net pack", value: "≤ 1 kg" },
60
+ ],
61
+ policies: [
62
+ { tone: "warning", title: "Health certificate required", text: "Aquatic-product imports need a health certificate from the competent authority of the exporting country before clearance." },
63
+ { tone: "info", title: "Origin can lower the rate", text: "A valid Form D / RCEP certificate of origin keeps the preferential 0% where the MFN line is non-zero." },
64
+ ],
65
+ sources: [
66
+ { id: "s1", label: "Tariff schedule 2026", kind: "knowledge", detail: "Ch. 03" },
67
+ { id: "s2", label: "Heading 0306.17", kind: "knowledge" },
68
+ { id: "s3", label: "Circular 31/2022 — VAT", kind: "document", detail: "Art. 1" },
69
+ ],
70
+ },
71
+ {
72
+ id: "v-shirt",
73
+ query: "Men's T-shirts, 100% cotton, knit, short sleeve",
74
+ code: "6109.10.00",
75
+ heading: "T-shirts, singlets & other vests, knitted or crocheted — of cotton",
76
+ confidence: "high",
77
+ basis: "Knitted construction of cotton → heading 6109; subheading .10 for cotton (not man-made fibres).",
78
+ duty: [
79
+ { label: "Import duty (MFN)", value: "20%" },
80
+ { label: "ATIGA (Form D)", value: "0%" },
81
+ { label: "VAT", value: "8%" },
82
+ ],
83
+ dutyTotal: { label: "Effective tax on CIF", value: "8–28%" },
84
+ specs: [
85
+ { label: "Chapter", value: "61 — Knitted apparel" },
86
+ { label: "Construction", value: "Knit" },
87
+ { label: "Material", value: "100% cotton" },
88
+ ],
89
+ policies: [
90
+ { tone: "info", title: "Origin proof is decisive", text: "Without a Form D the MFN 20% applies; with it the line drops to 0% — a 20-point swing on CIF." },
91
+ ],
92
+ sources: [
93
+ { id: "s1", label: "Tariff schedule 2026", kind: "knowledge", detail: "Ch. 61" },
94
+ { id: "s2", label: "ATIGA tariff", kind: "knowledge" },
95
+ ],
96
+ },
97
+ {
98
+ id: "v-batt",
99
+ query: "Lithium-ion battery packs for e-scooters, 48V",
100
+ code: "8507.60.10",
101
+ heading: "Lithium-ion accumulators — of a kind used as the primary source of power",
102
+ confidence: "medium",
103
+ basis: "Secondary (rechargeable) lithium-ion cells assembled into a pack → heading 8507.60. The national line turns on end-use; confirm the device class.",
104
+ duty: [
105
+ { label: "Import duty (MFN)", value: "5%" },
106
+ { label: "VAT", value: "10%" },
107
+ ],
108
+ dutyTotal: { label: "Effective tax on CIF", value: "15.5%" },
109
+ specs: [
110
+ { label: "Chapter", value: "85 — Electrical machinery" },
111
+ { label: "Cell chemistry", value: "Li-ion (rechargeable)" },
112
+ { label: "Nominal voltage", value: "48 V" },
113
+ ],
114
+ policies: [
115
+ { tone: "warning", title: "Dangerous-goods handling", text: "Lithium batteries ship under UN 3480 — IATA/IMDG packaging and declaration apply; carriers may require a test summary (UN 38.3)." },
116
+ ],
117
+ sources: [
118
+ { id: "s1", label: "Tariff schedule 2026", kind: "knowledge", detail: "Ch. 85" },
119
+ { id: "s2", label: "Heading 8507.60", kind: "knowledge" },
120
+ ],
121
+ },
122
+ ];
123
+
124
+ type ScriptStep = Omit<AgentRunStep, "status">;
125
+ const SCRIPT: ScriptStep[] = [
126
+ { id: "r1", label: "Reading the description", detail: "Form, material, processing and presentation extracted." },
127
+ { id: "r2", label: "tariff_schedule.search", kind: "tool" },
128
+ { id: "r3", label: "Narrowing the heading", detail: "Matching the goods against chapter notes and exclusions." },
129
+ { id: "r4", label: "duty_rates.read", kind: "tool" },
130
+ { id: "r5", label: "Checking import policies", detail: "Permits, certificates and trade-remedy orders for the line." },
131
+ ];
132
+
133
+ export function TplLookup() {
134
+ const [activeId, setActiveId] = useState(VERDICTS[0].id);
135
+ const [phase, setPhase] = useState<"running" | "ready">("ready");
136
+ const [revealed, setRevealed] = useState(SCRIPT.length);
137
+ const [prompt, setPrompt] = useState("");
138
+ const [runKey, setRunKey] = useState(0);
139
+ const timer = useRef<ReturnType<typeof setInterval> | null>(null);
140
+
141
+ const active = VERDICTS.find((v) => v.id === activeId) ?? VERDICTS[0];
142
+
143
+ useEffect(() => {
144
+ if (runKey === 0) return;
145
+ setPhase("running");
146
+ setRevealed(1);
147
+ let n = 1;
148
+ timer.current = setInterval(() => {
149
+ n += 1;
150
+ setRevealed(n);
151
+ if (n >= SCRIPT.length) {
152
+ if (timer.current) clearInterval(timer.current);
153
+ setPhase("ready");
154
+ }
155
+ }, 700);
156
+ return () => {
157
+ if (timer.current) clearInterval(timer.current);
158
+ };
159
+ }, [runKey]);
160
+
161
+ const ask = (text: string) => {
162
+ const hit =
163
+ VERDICTS.find((v) => {
164
+ const q = text.toLowerCase();
165
+ return (
166
+ (v.id === "v-shirt" && (q.includes("shirt") || q.includes("cotton") || q.includes("knit"))) ||
167
+ (v.id === "v-batt" && (q.includes("batter") || q.includes("lithium") || q.includes("cell"))) ||
168
+ (v.id === "v-shrimp" && (q.includes("shrimp") || q.includes("prawn") || q.includes("frozen")))
169
+ );
170
+ }) ?? VERDICTS[0];
171
+ setActiveId(hit.id);
172
+ setPrompt("");
173
+ setRunKey((k) => k + 1);
174
+ };
175
+
176
+ const streaming = phase === "running" && revealed < SCRIPT.length;
177
+ const steps: AgentRunStep[] = SCRIPT.slice(0, revealed).map((s, i) => ({
178
+ ...s,
179
+ status: streaming && i === revealed - 1 ? "running" : "done",
180
+ }));
181
+
182
+ return (
183
+ <View style={{ flex: 1, flexDirection: "row", backgroundColor: colors.zinc[50] }}>
184
+ {/* ── LEFT · command + session ───────────────────────────────── */}
185
+ <View style={{ width: 380, backgroundColor: colors.white, borderRightWidth: 1, borderRightColor: colors.zinc[200] }}>
186
+ <View style={{ paddingHorizontal: 20, paddingTop: 18, paddingBottom: 12, gap: 2 }}>
187
+ <Text size="md" weight="semibold">HS classification</Text>
188
+ <Text size="sm" color="muted">Describe the goods — the agent finds the code and explains the duty</Text>
189
+ </View>
190
+ <Divider />
191
+
192
+ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, gap: 16 }}>
193
+ {/* current run */}
194
+ <AgentRun title={`Classifying — ${active.query}`} steps={steps} state={streaming ? "streaming" : "done"} />
195
+
196
+ <View style={{ gap: 8 }}>
197
+ <Text size="xs" color="muted" weight="medium">This session</Text>
198
+ {VERDICTS.map((v) => (
199
+ <PressableHighlight
200
+ key={v.id}
201
+ onPress={() => { setActiveId(v.id); setPhase("ready"); setRevealed(SCRIPT.length); }}
202
+ accessibilityLabel={`Show ${v.query}`}
203
+ style={{
204
+ padding: 10,
205
+ borderRadius: 10,
206
+ borderWidth: 1,
207
+ borderColor: v.id === activeId ? colors.zinc[900] : colors.zinc[200],
208
+ backgroundColor: v.id === activeId ? colors.zinc[50] : colors.white,
209
+ gap: 3,
210
+ }}
211
+ >
212
+ <Text size="sm" weight="medium" numberOfLines={1}>{v.query}</Text>
213
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
214
+ <Text size="xs" color="muted" tabular>{v.code}</Text>
215
+ <Confidence level={v.confidence} />
216
+ </View>
217
+ </PressableHighlight>
218
+ ))}
219
+ </View>
220
+ </ScrollView>
221
+
222
+ <View style={{ padding: 12, borderTopWidth: 1, borderTopColor: colors.zinc[200] }}>
223
+ <PromptField
224
+ value={prompt}
225
+ onChangeText={setPrompt}
226
+ onSubmit={ask}
227
+ busy={phase === "running"}
228
+ placeholder="Describe goods, or refine — “what if it's pre-cooked?”"
229
+ />
230
+ </View>
231
+ </View>
232
+
233
+ {/* ── RIGHT · the pinned structured answer ───────────────────── */}
234
+ <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 28, paddingBottom: 48 }}>
235
+ <View style={{ width: "100%", maxWidth: 720, alignSelf: "center", gap: 16 }}>
236
+ <View style={{ borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 16, backgroundColor: colors.white, overflow: "hidden" }}>
237
+ {/* verdict header */}
238
+ <View style={{ padding: 22, gap: 10, backgroundColor: colors.zinc[50], borderBottomWidth: 1, borderBottomColor: colors.zinc[200] }}>
239
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
240
+ <Text size="xs" color="muted" weight="medium" style={{ flex: 1 }}>Suggested HS code</Text>
241
+ <Confidence level={active.confidence} />
242
+ </View>
243
+ <Text size="xxl" weight="semibold" tabular>{active.code}</Text>
244
+ <Text size="sm" color="muted">{active.heading}</Text>
245
+ </View>
246
+
247
+ <View style={{ padding: 22, gap: 18 }}>
248
+ <View style={{ borderLeftWidth: 2, borderLeftColor: colors.zinc[300], paddingLeft: 12 }}>
249
+ <Text size="sm" color="muted">{active.basis}</Text>
250
+ </View>
251
+
252
+ <View style={{ gap: 8 }}>
253
+ <Text size="sm" weight="semibold">Duty & tax</Text>
254
+ <SpecList rows={active.duty} total={active.dutyTotal} />
255
+ </View>
256
+
257
+ <Divider />
258
+
259
+ <View style={{ gap: 8 }}>
260
+ <Text size="sm" weight="semibold">Classification basis</Text>
261
+ <SpecList rows={active.specs} dense />
262
+ </View>
263
+
264
+ {active.policies.length > 0 ? (
265
+ <View style={{ gap: 10 }}>
266
+ <Text size="sm" weight="semibold">Policies that apply</Text>
267
+ {active.policies.map((p, i) => (
268
+ <Callout key={i} tone={p.tone}>
269
+ <CalloutTitle>{p.title}</CalloutTitle>
270
+ <CalloutText>{p.text}</CalloutText>
271
+ </Callout>
272
+ ))}
273
+ </View>
274
+ ) : null}
275
+
276
+ <Divider />
277
+ <Sources sources={active.sources} onOpen={() => {}} />
278
+ </View>
279
+ </View>
280
+
281
+ <View style={{ paddingHorizontal: 4 }}>
282
+ <Text size="xs" color="muted">A suggested classification — the declarant confirms the code before filing.</Text>
283
+ </View>
284
+ </View>
285
+ </ScrollView>
286
+ </View>
287
+ );
288
+ }