@lotics/ui 7.19.2 → 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.
@@ -7,7 +7,6 @@ import { Callout, CalloutText, CalloutTitle } from "@lotics/ui/callout";
7
7
  import { PressableHighlight } from "@lotics/ui/pressable_highlight";
8
8
  import { Composer } from "@lotics/ui/composer";
9
9
  import { AgentRun, type AgentRunStep, type AgentRunItem } from "@lotics/ui/agent_run";
10
- import { SpecList, type SpecRow } from "@lotics/ui/spec_list";
11
10
  import { Confidence, type ConfidenceLevel } from "@lotics/ui/confidence";
12
11
  import { Badge } from "@lotics/ui/badge";
13
12
  import { Sources, type SourceRef } from "@lotics/ui/sources";
@@ -19,7 +18,7 @@ import { Sources, type SourceRef } from "@lotics/ui/sources";
19
18
  // single confident verdict: classification is ambiguous, so the alternatives are
20
19
  // first-class (the prep/pack push the goods to a different code with a different
21
20
  // duty). The answer is a panel of scannable components — the code, the exact duty
22
- // breakdown (SpecList), the policies, the sources. Refine the description ("what
21
+ // breakdown (hand-composed spec rows), the policies, the sources. Refine the description ("what
23
22
  // if it's pre-cooked?") and the ranking updates. Same shape for any
24
23
  // look-up-and-explain: tariff/HS, fee lookup, policy Q&A, a spec/compliance desk.
25
24
  // ─────────────────────────────────────────────────────────────────────────────
@@ -46,7 +45,6 @@ interface Candidate {
46
45
  // One query, several plausible HS codes — the agent RANKS them; the declarant
47
46
  // picks. The prep and the pack push the goods to a different code with a
48
47
  // different duty, which is exactly why a single confident verdict is wrong.
49
- const QUERY = "Black tiger shrimp, headless shell-on, IQF frozen, 1 kg retail packs";
50
48
 
51
49
  const CANDIDATES: Candidate[] = [
52
50
  {
@@ -204,9 +202,11 @@ export function TplLookup() {
204
202
  {/* current run */}
205
203
  <AgentRun items={items} state={streaming ? "streaming" : "done"} />
206
204
 
205
+ {/* Matches surface only once the run settles — the agent's output,
206
+ never a pre-filled list; they sit at the bottom, under the run. */}
207
+ {phase === "ready" ? (
207
208
  <View style={{ gap: 8 }}>
208
209
  <Text size="xs" color="muted" weight="medium">Nearest matches</Text>
209
- <Text size="xs" color="muted" numberOfLines={2}>{QUERY}</Text>
210
210
  {CANDIDATES.map((c) => (
211
211
  <PressableHighlight
212
212
  key={c.id}
@@ -231,6 +231,7 @@ export function TplLookup() {
231
231
  </PressableHighlight>
232
232
  ))}
233
233
  </View>
234
+ ) : null}
234
235
  </ScrollView>
235
236
 
236
237
  <View style={{ padding: 12, borderTopWidth: 1, borderTopColor: colors.zinc[200] }}>
@@ -247,9 +248,9 @@ export function TplLookup() {
247
248
  {/* ── RIGHT · the pinned structured answer ───────────────────── */}
248
249
  <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 28, paddingBottom: 48 }}>
249
250
  <View style={{ width: "100%", maxWidth: 720, alignSelf: "center", gap: 16 }}>
250
- <View style={{ borderWidth: 1, borderColor: colors.zinc[200], borderRadius: 16, backgroundColor: colors.white, overflow: "hidden" }}>
251
- {/* verdict header */}
252
- <View style={{ padding: 22, gap: 10, backgroundColor: colors.white, borderBottomWidth: 1, borderBottomColor: colors.zinc[200] }}>
251
+ <View style={{ gap: 4 }}>
252
+ {/* verdict header — open, no card box: the panel IS the answer */}
253
+ <View style={{ paddingVertical: 6, gap: 10 }}>
253
254
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
254
255
  <Text size="xs" color="muted" weight="medium" style={{ flex: 1 }}>Suggested HS code</Text>
255
256
  <Confidence level={active.confidence} />
@@ -258,21 +259,22 @@ export function TplLookup() {
258
259
  <Text size="sm" color="muted">{active.heading}</Text>
259
260
  </View>
260
261
 
261
- <View style={{ padding: 22, gap: 18 }}>
262
+ <Divider />
263
+ <View style={{ paddingVertical: 16, gap: 18 }}>
262
264
  <View style={{ borderLeftWidth: 2, borderLeftColor: colors.zinc[300], paddingLeft: 12 }}>
263
265
  <Text size="sm" color="muted">{active.basis}</Text>
264
266
  </View>
265
267
 
266
268
  <View style={{ gap: 8 }}>
267
269
  <Text size="sm" weight="semibold">Duty & tax</Text>
268
- <SpecList rows={active.duty} total={active.dutyTotal} />
270
+ <SpecRows rows={active.duty} total={active.dutyTotal} />
269
271
  </View>
270
272
 
271
273
  <Divider />
272
274
 
273
275
  <View style={{ gap: 8 }}>
274
276
  <Text size="sm" weight="semibold">Classification basis</Text>
275
- <SpecList rows={active.specs} dense />
277
+ <SpecRows rows={active.specs} dense />
276
278
  </View>
277
279
 
278
280
  {active.policies.length > 0 ? (
@@ -300,3 +302,28 @@ export function TplLookup() {
300
302
  </View>
301
303
  );
302
304
  }
305
+
306
+ // The structured label → value breakdown behind the answer — the duty table,
307
+ // the classification basis. Hand-composed two-column rows: hierarchy from
308
+ // weight and tabular figures; the total pins under a divider.
309
+ interface SpecRow { label: string; value: string }
310
+
311
+ function SpecRows({ rows, total, dense }: { rows: SpecRow[]; total?: SpecRow; dense?: boolean }) {
312
+ const line = (row: SpecRow, isTotal?: boolean) => (
313
+ <View key={row.label} style={{ flexDirection: "row", alignItems: "baseline", justifyContent: "space-between", gap: 16, minHeight: dense ? 26 : 32 }}>
314
+ <Text size={dense ? "xs" : "sm"} color={isTotal ? "default" : "muted"} weight={isTotal ? "semibold" : "regular"} numberOfLines={1} style={{ flexShrink: 1 }}>{row.label}</Text>
315
+ <Text size={dense && !isTotal ? "sm" : "md"} weight={isTotal ? "semibold" : "medium"} tabular>{row.value}</Text>
316
+ </View>
317
+ );
318
+ return (
319
+ <View>
320
+ {rows.map((r) => line(r))}
321
+ {total ? (
322
+ <>
323
+ <Divider paddingVertical={dense ? 5 : 7} />
324
+ {line(total, true)}
325
+ </>
326
+ ) : null}
327
+ </View>
328
+ );
329
+ }
@@ -28,7 +28,7 @@ import { DataGrid, gridRowStyle, type DataGridColumn, type DataGridGroup } from
28
28
  import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
29
29
  import { Composer } from "@lotics/ui/composer";
30
30
  import { AgentRun, type AgentRunItem } from "@lotics/ui/agent_run";
31
- import { ChangeReview, ChangeReviewActions, type ChangeReviewItemStatus } from "@lotics/ui/change_review";
31
+ import { Change, ChangeReview, ChangeReviewActions, ChangeReviewHeader, ChangeSummary, type ChangeStatus } from "@lotics/ui/change_review";
32
32
  import { Dialog, DialogHeader, DialogHeaderTitle, DialogScrollArea, DialogFooter } from "@lotics/ui/dialog";
33
33
  import { CompletionState } from "@lotics/ui/completion_state";
34
34
  import { Confidence, type ConfidenceLevel } from "@lotics/ui/confidence";
@@ -225,7 +225,7 @@ export function TplTaskBoard() {
225
225
  const [phase, setPhase] = useState<Phase>("idle");
226
226
  const [revealed, setRevealed] = useState(0);
227
227
  const [proposals, setProposals] = useState<Proposal[]>([]);
228
- const [decisions, setDecisions] = useState<Record<string, ChangeReviewItemStatus>>({});
228
+ const [decisions, setDecisions] = useState<Record<string, ChangeStatus>>({});
229
229
  const [addedCount, setAddedCount] = useState(0);
230
230
 
231
231
  const startDraft = useCallback(() => { setRevealed(0); setPhase("reading"); }, []);
@@ -233,7 +233,7 @@ export function TplTaskBoard() {
233
233
  const closeDraft = useCallback(() => { setDraftOpen(false); resetDraft(); }, [resetDraft]);
234
234
  const editProposal = useCallback((id: string, p: Partial<Proposal>) =>
235
235
  setProposals((ps) => ps.map((x) => (x.id === id ? { ...x, ...p } : x))), []);
236
- const decide = useCallback((id: string, status: ChangeReviewItemStatus | null) =>
236
+ const decide = useCallback((id: string, status: ChangeStatus | null) =>
237
237
  setDecisions((dec) => { const n = { ...dec }; if (status) n[id] = status; else delete n[id]; return n; }), []);
238
238
  const acceptedCount = proposals.filter((p) => decisions[p.id] === "accepted").length;
239
239
  const addAccepted = useCallback(() => {
@@ -371,7 +371,10 @@ export function TplTaskBoard() {
371
371
  </View>
372
372
  </ScrollView>
373
373
 
374
- {/* Draft from notes — Composer → AgentRun → ChangeReview → commit (adds tasks). */}
374
+ {/* Draft from notes — Composer → AgentRun → ChangeReview → commit (adds tasks).
375
+ The review provider wraps the WHOLE dialog so the `Change`s (scroll area)
376
+ and the commit bar (`DialogFooter`) share one review context. */}
377
+ <ChangeReview>
375
378
  <Dialog open={draftOpen} onOpenChange={(o) => { if (!o) closeDraft(); }} maxWidth={620}>
376
379
  <DialogHeader><DialogHeaderTitle>Draft from notes</DialogHeaderTitle></DialogHeader>
377
380
  <DialogScrollArea>
@@ -383,20 +386,29 @@ export function TplTaskBoard() {
383
386
  ) : null}
384
387
  {phase === "reading" ? <AgentRun items={runItems} state={revealed >= DRAFT_STEPS.length ? "done" : "streaming"} /> : null}
385
388
  {phase === "review" ? (
386
- <ChangeReview
387
- title="Drafted tasks"
388
- summary={`${proposals.length} from your notes — keep, edit, or drop each. A “Low” mark is worth a glance.`}
389
- items={proposals}
390
- getKey={(p) => p.id}
391
- statusOf={(p) => decisions[p.id] ?? "pending"}
392
- renderItem={(p) => <TaskProposal proposal={p} onEdit={editProposal} />}
393
- renderSummary={(p, kept) => (
394
- <Text size="sm" numberOfLines={1} color={kept ? "default" : "muted"} style={kept ? undefined : { textDecorationLine: "line-through" }}>{p.title}</Text>
395
- )}
396
- onAcceptItem={(i) => decide(proposals[i].id, "accepted")}
397
- onRejectItem={(i) => decide(proposals[i].id, "rejected")}
398
- onUndoItem={(i) => decide(proposals[i].id, null)}
399
- />
389
+ <View style={{ gap: 8 }}>
390
+ <ChangeReviewHeader title="Drafted tasks" />
391
+ <Text size="sm" color="muted">{`${proposals.length} from your notes — keep, edit, or drop each. A “Low” mark is worth a glance.`}</Text>
392
+ {proposals.map((p) => {
393
+ const status = decisions[p.id] ?? "pending";
394
+ const kept = status === "accepted";
395
+ return (
396
+ <Change
397
+ key={p.id}
398
+ id={p.id}
399
+ status={status}
400
+ onAccept={() => decide(p.id, "accepted")}
401
+ onReject={() => decide(p.id, "rejected")}
402
+ onUndo={() => decide(p.id, null)}
403
+ >
404
+ <TaskProposal proposal={p} onEdit={editProposal} />
405
+ <ChangeSummary>
406
+ <Text size="sm" numberOfLines={1} color={kept ? "default" : "muted"} style={kept ? undefined : { textDecorationLine: "line-through" }}>{p.title}</Text>
407
+ </ChangeSummary>
408
+ </Change>
409
+ );
410
+ })}
411
+ </View>
400
412
  ) : null}
401
413
  {phase === "done" ? (
402
414
  <CompletionState title={`${addedCount} ${addedCount === 1 ? "task" : "tasks"} added`} summary="They're on the board now." />
@@ -404,7 +416,7 @@ export function TplTaskBoard() {
404
416
  </DialogScrollArea>
405
417
  {phase === "review" ? (
406
418
  <DialogFooter>
407
- <ChangeReviewActions items={proposals} statusOf={(p) => decisions[p.id] ?? "pending"} onAcceptItem={(i) => decide(proposals[i].id, "accepted")} onApply={addAccepted} onDiscard={resetDraft} applyLabel={`Add ${acceptedCount} ${acceptedCount === 1 ? "task" : "tasks"}`} />
419
+ <ChangeReviewActions onApply={addAccepted} onDiscard={resetDraft} applyLabel={`Add ${acceptedCount} ${acceptedCount === 1 ? "task" : "tasks"}`} />
408
420
  </DialogFooter>
409
421
  ) : phase === "done" ? (
410
422
  <DialogFooter>
@@ -413,6 +425,7 @@ export function TplTaskBoard() {
413
425
  </DialogFooter>
414
426
  ) : null}
415
427
  </Dialog>
428
+ </ChangeReview>
416
429
  </>
417
430
  );
418
431
  }
@@ -18,7 +18,7 @@ import { FilesEditor } from "@lotics/ui/files_editor";
18
18
  import type { DisplayFile } from "@lotics/ui/file_thumbnail";
19
19
  import { Composer } from "@lotics/ui/composer";
20
20
  import { AgentRun, type AgentRunItem } from "@lotics/ui/agent_run";
21
- import { ChangeReview, ChangeReviewActions, type ChangeReviewItemStatus } from "@lotics/ui/change_review";
21
+ import { Change, ChangeReview, ChangeReviewActions, ChangeReviewHeader, ChangeSummary, type ChangeStatus } from "@lotics/ui/change_review";
22
22
  import { Dialog, DialogHeader, DialogHeaderTitle, DialogScrollArea, DialogFooter } from "@lotics/ui/dialog";
23
23
  import { CompletionState } from "@lotics/ui/completion_state";
24
24
  import { Confidence, type ConfidenceLevel } from "@lotics/ui/confidence";
@@ -160,7 +160,7 @@ export function TplTasks() {
160
160
  const [phase, setPhase] = useState<Phase>("idle");
161
161
  const [revealed, setRevealed] = useState(0);
162
162
  const [proposals, setProposals] = useState<Proposal[]>([]);
163
- const [decisions, setDecisions] = useState<Record<string, ChangeReviewItemStatus>>({});
163
+ const [decisions, setDecisions] = useState<Record<string, ChangeStatus>>({});
164
164
  const [addedCount, setAddedCount] = useState(0);
165
165
 
166
166
  const patch = (id: string, next: Partial<Task>) =>
@@ -182,7 +182,7 @@ export function TplTasks() {
182
182
  const closeDraft = useCallback(() => { setDraftOpen(false); resetDraft(); }, [resetDraft]);
183
183
  const editProposal = useCallback((id: string, p: Partial<Proposal>) =>
184
184
  setProposals((ps) => ps.map((x) => (x.id === id ? { ...x, ...p } : x))), []);
185
- const decide = useCallback((id: string, status: ChangeReviewItemStatus | null) =>
185
+ const decide = useCallback((id: string, status: ChangeStatus | null) =>
186
186
  setDecisions((dec) => { const n = { ...dec }; if (status) n[id] = status; else delete n[id]; return n; }), []);
187
187
  const acceptedCount = proposals.filter((p) => decisions[p.id] === "accepted").length;
188
188
  const addAccepted = useCallback(() => {
@@ -345,7 +345,10 @@ export function TplTasks() {
345
345
  </View>
346
346
  </ScrollView>
347
347
 
348
- {/* Draft from notes — Composer → AgentRun → ChangeReview → commit. */}
348
+ {/* Draft from notes — Composer → AgentRun → ChangeReview → commit. The review
349
+ provider wraps the WHOLE dialog so the `Change`s (scroll area) and the
350
+ commit bar (`DialogFooter`) share one review context. */}
351
+ <ChangeReview>
349
352
  <Dialog open={draftOpen} onOpenChange={(o) => { if (!o) closeDraft(); }} maxWidth={620}>
350
353
  <DialogHeader><DialogHeaderTitle>Draft from notes</DialogHeaderTitle></DialogHeader>
351
354
  <DialogScrollArea>
@@ -357,20 +360,29 @@ export function TplTasks() {
357
360
  ) : null}
358
361
  {phase === "reading" ? <AgentRun items={runItems} state={revealed >= DRAFT_STEPS.length ? "done" : "streaming"} /> : null}
359
362
  {phase === "review" ? (
360
- <ChangeReview
361
- title="Drafted tasks"
362
- summary={`${proposals.length} from your notes — keep, edit, or drop each. A “Low” mark is worth a glance.`}
363
- items={proposals}
364
- getKey={(p) => p.id}
365
- statusOf={(p) => decisions[p.id] ?? "pending"}
366
- renderItem={(p) => <TaskProposal proposal={p} onEdit={editProposal} />}
367
- renderSummary={(p, kept) => (
368
- <Text size="sm" numberOfLines={1} color={kept ? "default" : "muted"} style={kept ? undefined : { textDecorationLine: "line-through" }}>{p.title}</Text>
369
- )}
370
- onAcceptItem={(i) => decide(proposals[i].id, "accepted")}
371
- onRejectItem={(i) => decide(proposals[i].id, "rejected")}
372
- onUndoItem={(i) => decide(proposals[i].id, null)}
373
- />
363
+ <View style={{ gap: 8 }}>
364
+ <ChangeReviewHeader title="Drafted tasks" />
365
+ <Text size="sm" color="muted">{`${proposals.length} from your notes — keep, edit, or drop each. A “Low” mark is worth a glance.`}</Text>
366
+ {proposals.map((p) => {
367
+ const status = decisions[p.id] ?? "pending";
368
+ const kept = status === "accepted";
369
+ return (
370
+ <Change
371
+ key={p.id}
372
+ id={p.id}
373
+ status={status}
374
+ onAccept={() => decide(p.id, "accepted")}
375
+ onReject={() => decide(p.id, "rejected")}
376
+ onUndo={() => decide(p.id, null)}
377
+ >
378
+ <TaskProposal proposal={p} onEdit={editProposal} />
379
+ <ChangeSummary>
380
+ <Text size="sm" numberOfLines={1} color={kept ? "default" : "muted"} style={kept ? undefined : { textDecorationLine: "line-through" }}>{p.title}</Text>
381
+ </ChangeSummary>
382
+ </Change>
383
+ );
384
+ })}
385
+ </View>
374
386
  ) : null}
375
387
  {phase === "done" ? (
376
388
  <CompletionState title={`${addedCount} ${addedCount === 1 ? "task" : "tasks"} added`} summary="They're in your list, grouped by due date." />
@@ -378,7 +390,7 @@ export function TplTasks() {
378
390
  </DialogScrollArea>
379
391
  {phase === "review" ? (
380
392
  <DialogFooter>
381
- <ChangeReviewActions items={proposals} statusOf={(p) => decisions[p.id] ?? "pending"} onAcceptItem={(i) => decide(proposals[i].id, "accepted")} onApply={addAccepted} onDiscard={resetDraft} applyLabel={`Add ${acceptedCount} ${acceptedCount === 1 ? "task" : "tasks"}`} />
393
+ <ChangeReviewActions onApply={addAccepted} onDiscard={resetDraft} applyLabel={`Add ${acceptedCount} ${acceptedCount === 1 ? "task" : "tasks"}`} />
382
394
  </DialogFooter>
383
395
  ) : phase === "done" ? (
384
396
  <DialogFooter>
@@ -387,6 +399,7 @@ export function TplTasks() {
387
399
  </DialogFooter>
388
400
  ) : null}
389
401
  </Dialog>
402
+ </ChangeReview>
390
403
  </>
391
404
  );
392
405
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "7.19.2",
3
+ "version": "8.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./tokens": "./src/tokens.ts",
@@ -81,18 +81,11 @@
81
81
  },
82
82
  "./markdown.css": "./src/markdown.css",
83
83
  "./confidence": "./src/confidence.tsx",
84
- "./review_card": "./src/review_card.tsx",
85
84
  "./change_review": "./src/change_review.tsx",
85
+ "./finding": "./src/finding.tsx",
86
86
  "./clarify": "./src/clarify.tsx",
87
87
  "./choice_list": "./src/choice_list.tsx",
88
88
  "./sources": "./src/sources.tsx",
89
- "./record_fields": "./src/record_fields.tsx",
90
- "./spec_list": "./src/spec_list.tsx",
91
- "./match_sides": "./src/match_sides.tsx",
92
- "./finding": "./src/finding.tsx",
93
- "./discrepancy": "./src/discrepancy.tsx",
94
- "./triage_row": "./src/triage_row.tsx",
95
- "./scored_option": "./src/scored_option.tsx",
96
89
  "./icon": "./src/icon.tsx",
97
90
  "./dynamic_icon": {
98
91
  "react-native": "./src/dynamic_icon.tsx",
package/src/agent_run.tsx CHANGED
@@ -147,7 +147,7 @@ const INK = colors.zinc[700];
147
147
  * fires (not a growing stack of dots); once prose resumes the group settles into
148
148
  * one row — "{final action} · {n} steps" — that EXPANDS on press to the steps in
149
149
  * between. Text segments render as prose, the last one carrying a live caret. The
150
- * run always ends on the agent's text. Pair with `Composer` + `ReviewCard`.
150
+ * run always ends on the agent's text. Pair with `Composer` + `ChangeReview`.
151
151
  */
152
152
  export function AgentRun(props: AgentRunProps) {
153
153
  const { items, labelForTool, stepsLabel = (n) => `${n} steps`, accessibilityLabel } = props;