@lotics/ui 14.1.0 → 14.3.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.
@@ -1,5 +1,6 @@
1
- import { useState, useRef } from "react";
1
+ import { useEffect, useState, useRef } from "react";
2
2
  import { ScrollView, View } from "react-native";
3
+ import type { UIMessagePart, UIDataTypes, UITools } from "ai";
3
4
  import { Text } from "@lotics/ui/text";
4
5
  import { colors, type ColorName } from "@lotics/ui/colors";
5
6
  import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
@@ -50,7 +51,14 @@ import { FileThumbnailGrid } from "@lotics/ui/file_thumbnail_grid";
50
51
  import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
51
52
  import { Ledger, LedgerGroup, LedgerRow, LedgerTotal } from "@lotics/ui/ledger";
52
53
  import { ProgressBar } from "@lotics/ui/progress_bar";
53
- import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle } from "@lotics/ui/dialog";
54
+ import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle, DialogScrollArea } from "@lotics/ui/dialog";
55
+ import { AgentRun } from "@lotics/ui/agent_run";
56
+ import { FollowScroll } from "@lotics/ui/follow_scroll";
57
+ import { ClarifyWizard, type ClarifyWizardAnswer, type ClarifyWizardQuestion } from "@lotics/ui/clarify_wizard";
58
+ import { CardSelectItem } from "@lotics/ui/card_select_item";
59
+ import { FileDropzone } from "@lotics/ui/file_dropzone";
60
+ import { ChangeField, ChangeRecord, ChangeReview, ChangeReviewActions, ChangeReviewHeader, ChangeValueInput, type ChangeStatus } from "@lotics/ui/change_review";
61
+ import { useScreenSize } from "@lotics/ui/use_screen_size";
54
62
  import { MemberSelect } from "@lotics/ui/member_select";
55
63
  import { Callout, CalloutText } from "@lotics/ui/callout";
56
64
  import { Timeline, type TimelineItem } from "@lotics/ui/timeline";
@@ -1025,71 +1033,294 @@ function LinkedRecordScreen({ ma }: { ma: string }) {
1025
1033
  );
1026
1034
  }
1027
1035
 
1028
- /** New record is a THREE-field gate past the popover threshold (1–2
1029
- * fields), so it's a Dialog. NOT a wizard: create-then-refine means the
1030
- * gate stays minimal and the record's own workspace (the drawer it opens
1031
- * into) is where everything else is refined. */
1032
- function CreateRecordDialog({ onCreate }: { onCreate: (khach: string, dienThoai: string, phi: number) => void }) {
1036
+ // ─── Enter data — the INTAKE fork ────────────────────────────────────────────
1037
+ // AI FIRST, form as fallback. The one "Enter data" CTA opens a phased dialog:
1038
+ // drop files (the hero) a short ANALYZE stream reads them → `ClarifyWizard`
1039
+ // asks the genuine ambiguities the analysis surfaced → the IMPORT stream drafts
1040
+ // the records `ChangeRecord` cards to review + apply (rows really land).
1041
+ // Below the dropzone, the MANUAL variants (each flavors the same 3-field
1042
+ // create-then-refine gate). The mock plays the clarify step as two phases; a
1043
+ // real app can run it as ONE agent run — every app agent carries
1044
+ // `ask_user_choice`, so the run parks on the agent's own question and
1045
+ // `useAgentRun().pendingChoice`/`answerChoice` drive this same wizard.
1046
+
1047
+ type Part = UIMessagePart<UIDataTypes, UITools>;
1048
+ type IntakePhase = "intake" | "analyze" | "clarify" | "running" | "review" | "form";
1049
+ type ManualVariant = "export" | "import";
1050
+
1051
+ type Proposal = { id: string; khach: string; dienThoai: string; phi: number; orders: string };
1052
+
1053
+ // What the (mock) import drafts, per the wizard's grouping answer. A real app
1054
+ // takes these from the import run's structured output.
1055
+ const PROPOSED_BY_CUSTOMER: Proposal[] = [
1056
+ { id: "pc1", khach: "Meridian Trading Co.", dienThoai: "(555) 014-2288", phi: 240, orders: "PO-7311 · PO-7314" },
1057
+ { id: "pc2", khach: "Northgate Textiles", dienThoai: "(555) 019-4471", phi: 180, orders: "PO-7312 · PO-7316" },
1058
+ { id: "pc3", khach: "Blue Harbor Foods", dienThoai: "", phi: 210, orders: "PO-7313 · PO-7315" },
1059
+ ];
1060
+ const PROPOSED_BY_ORDER: Proposal[] = PROPOSED_BY_CUSTOMER.flatMap((c) =>
1061
+ c.orders.split(" · ").map((po, i) => ({ id: `${c.id}-${i}`, khach: c.khach, dienThoai: c.dienThoai, phi: Math.round(c.phi / 2), orders: po })),
1062
+ );
1063
+
1064
+ // The wizard's questions come FROM the analysis (a real app renders them off the
1065
+ // analyze run's structured output) — informed, described, one custom-answer slot.
1066
+ const INTAKE_QUESTIONS: ClarifyWizardQuestion[] = [
1067
+ {
1068
+ question: "6 orders across 3 customers. How should they become records?",
1069
+ answers: [
1070
+ { value: "customer", label: "One record per customer", description: "3 records — each customer's orders grouped into one workspace and checklist." },
1071
+ { value: "order", label: "One record per order", description: "6 records — every order tracked on its own; more rows, finer-grained status." },
1072
+ ],
1073
+ },
1074
+ {
1075
+ question: "Blue Harbor Foods isn't in the customer book yet. What should happen?",
1076
+ allowCustom: true,
1077
+ answers: [
1078
+ { value: "create", label: "Create the customer", description: "A new customer record is added and linked as the records land." },
1079
+ { value: "skip", label: "Leave unassigned", description: "The records are created without a customer — link one later from each record." },
1080
+ ],
1081
+ },
1082
+ ];
1083
+
1084
+ const doneTool = (id: string, name: string): Part => ({ type: "dynamic-tool", toolName: name, toolCallId: id, state: "output-available", input: undefined, output: undefined });
1085
+
1086
+ const analyzeScript = (fileNames: string[]): Part[] => [
1087
+ { type: "reasoning", text: "Before drafting anything I need to know what these files contain and whether the parties already exist in the book." },
1088
+ ...fileNames.map((n, i) => doneTool(`a${i}`, `Read ${n}`)),
1089
+ doneTool("a-match", "Match customers"),
1090
+ { type: "text", text: "Found **6 order confirmations** across **3 customers** — one customer isn't in the book yet. Two quick questions before I draft the records." },
1091
+ ];
1092
+
1093
+ const importScript = (grouping: "customer" | "order"): Part[] => [
1094
+ { type: "reasoning", text: grouping === "customer" ? "Group the six orders under their three customers, one record each, fees summed per customer." : "One record per order — six records, fees split from the order totals." },
1095
+ doneTool("i1", "Extract order lines"),
1096
+ doneTool("i2", "Match customers"),
1097
+ doneTool("i3", grouping === "customer" ? "Draft 3 records" : "Draft 6 records"),
1098
+ { type: "text", text: `Drafted **${grouping === "customer" ? 3 : 6} records** — review below, edit any value, then apply.` },
1099
+ ];
1100
+
1101
+ /** The manual gate stays the THREE-field create-then-refine form — past the
1102
+ * popover threshold (1–2 fields), so a Dialog pane; NOT a wizard: the record's
1103
+ * own workspace (the drawer it opens into) is where everything else is refined. */
1104
+ function EnterDataDialog({ onCreate, onCreateMany }: {
1105
+ onCreate: (khach: string, dienThoai: string, phi: number) => void;
1106
+ onCreateMany: (records: { khach: string; dienThoai: string; phi: number }[]) => void;
1107
+ }) {
1108
+ const { small } = useScreenSize();
1033
1109
  const [open, setOpen] = useState(false);
1110
+ const [phase, setPhase] = useState<IntakePhase>("intake");
1111
+ const [fileNames, setFileNames] = useState<string[]>([]);
1112
+ const [variant, setVariant] = useState<ManualVariant>("export");
1113
+ const [grouping, setGrouping] = useState<"customer" | "order">("customer");
1114
+ // review state — per-card verdicts + the one editable value (the fee)
1115
+ const [cardStatus, setCardStatus] = useState<Record<string, ChangeStatus>>({});
1116
+ const [fees, setFees] = useState<Record<string, string>>({});
1117
+ // form state (the manual gate)
1034
1118
  const [khach, setKhach] = useState("");
1035
1119
  const [dienThoai, setDienThoai] = useState("");
1036
1120
  const [phi, setPhi] = useState<number | null>(null);
1037
- const create = () => {
1038
- if (khach.trim() === "") return;
1039
- onCreate(khach.trim(), dienThoai.trim(), phi ?? 0);
1040
- setOpen(false);
1121
+
1122
+ // The stream reveal — one revealer serves both the analyze and import phases.
1123
+ const [revealed, setRevealed] = useState(0);
1124
+ const script = phase === "analyze" ? analyzeScript(fileNames) : phase === "running" ? importScript(grouping) : [];
1125
+ useEffect(() => {
1126
+ if (phase !== "analyze" && phase !== "running") return;
1127
+ setRevealed(0);
1128
+ const total = phase === "analyze" ? analyzeScript(fileNames).length : importScript(grouping).length;
1129
+ let n = 0;
1130
+ const t = setInterval(() => {
1131
+ n += 1;
1132
+ setRevealed(n);
1133
+ if (n >= total) {
1134
+ clearInterval(t);
1135
+ // Auto-advance: analysis flows into the wizard; the import into review.
1136
+ setTimeout(() => setPhase(phase === "analyze" ? "clarify" : "review"), 500);
1137
+ }
1138
+ }, 650);
1139
+ return () => clearInterval(t);
1140
+ // Deliberately keyed on `phase` alone — the file list and grouping are
1141
+ // settled before their stream phase begins.
1142
+ }, [phase]);
1143
+
1144
+ const proposals = grouping === "customer" ? PROPOSED_BY_CUSTOMER : PROPOSED_BY_ORDER;
1145
+ // Kept = explicitly ACCEPTED (the kit's review contract): Apply stays disabled
1146
+ // at 0 kept, and the bar's own Keep-all presses every pending card's Keep.
1147
+ const keptCount = proposals.filter((p) => cardStatus[p.id] === "accepted").length;
1148
+
1149
+ const reset = () => {
1150
+ setPhase("intake");
1151
+ setFileNames([]);
1152
+ setCardStatus({});
1153
+ setFees({});
1041
1154
  setKhach("");
1042
1155
  setDienThoai("");
1043
1156
  setPhi(null);
1044
1157
  };
1158
+ const close = () => { setOpen(false); reset(); };
1159
+
1160
+ const applyProposals = () => {
1161
+ const kept = proposals.filter((p) => cardStatus[p.id] === "accepted");
1162
+ onCreateMany(kept.map((p) => ({ khach: p.khach, dienThoai: p.dienThoai, phi: Number(fees[p.id] ?? p.phi) || 0 })));
1163
+ close();
1164
+ };
1165
+
1166
+ const createManual = () => {
1167
+ if (khach.trim() === "") return;
1168
+ onCreate(khach.trim(), dienThoai.trim(), phi ?? 0);
1169
+ close();
1170
+ };
1171
+
1172
+ const title =
1173
+ phase === "form" ? (variant === "export" ? "New export case" : "New import case")
1174
+ : phase === "intake" ? "Enter data"
1175
+ : "Import from files";
1176
+
1045
1177
  return (
1046
1178
  <>
1047
- <Button title="New record" color="primary" onPress={() => setOpen(true)} />
1048
- <Dialog width={440} open={open} onOpenChange={setOpen}>
1179
+ <Button title="Enter data" color="primary" onPress={() => setOpen(true)} />
1180
+ <ChangeReview>
1181
+ <Dialog width={phase === "review" ? 560 : 480} open={open} onOpenChange={(o) => { if (!o) close(); }}>
1049
1182
  <DialogHeader>
1050
- <DialogHeaderTitle>New record</DialogHeaderTitle>
1183
+ <DialogHeaderTitle>{title}</DialogHeaderTitle>
1051
1184
  </DialogHeader>
1052
- <View style={{ paddingHorizontal: 24, paddingBottom: 12, gap: 12 }}>
1053
- <Text size="xs" color="muted">The record starts in Processing and opens for refinement — details, tasks, and payment live on the record itself.</Text>
1054
- <FormField label="Customer">
1055
- {khach === "" ? (
1056
- /* find-or-create: the input stays a pure search (reflectSelection
1057
- off a custom "create" pick has no option to reflect anyway);
1058
- the pick renders BELOW as the attached line with Change */
1059
- <Combobox
1060
- options={CUSTOMER_OPTIONS}
1061
- onValueChange={(opt) => setKhach(opt.label ?? opt.value)}
1062
- reflectSelection={false}
1063
- allowCustom
1064
- customOptionPlacement="top"
1065
- customOptionLabel={(q) => `Create new customer “${q}”`}
1066
- >
1067
- <ComboboxInput icon="search" placeholder="Search customers, or add a new one…" accessibilityLabel="Customer" />
1068
- <ComboboxContent />
1069
- </Combobox>
1070
- ) : (
1071
- <View style={{ flexDirection: "row", alignItems: "center", gap: 8, minHeight: 40 }}>
1072
- <Icon name={CUSTOMER_OPTIONS.some((o) => o.label === khach) ? "building-2" : "plus"} size={16} color={colors.zinc[400]} />
1073
- <Text size="sm" weight="medium" style={{ flex: 1 }} numberOfLines={1}>
1074
- {khach}
1075
- {CUSTOMER_OPTIONS.some((o) => o.label === khach) ? "" : " · new customer"}
1076
- </Text>
1077
- <Link size="sm" onPress={() => setKhach("")} accessibilityLabel="Change customer">Change</Link>
1078
- </View>
1079
- )}
1080
- </FormField>
1081
- <FormField label="Phone" optional optionalLabel="Optional">
1082
- <TextInputField value={dienThoai} onChangeText={setDienThoai} placeholder="(555) 000-0000" accessibilityLabel="Phone" />
1083
- </FormField>
1084
- <FormField label="Service fee">
1085
- <NumberInput value={phi} onValueChange={setPhi} min={0} accessibilityLabel="Service fee" />
1086
- </FormField>
1087
- </View>
1088
- <DialogFooter>
1089
- <Button title="Cancel" color="secondary" onPress={() => setOpen(false)} />
1090
- <Button title="Create record" color="primary" disabled={khach.trim() === ""} onPress={create} />
1091
- </DialogFooter>
1185
+
1186
+ {phase === "intake" ? (
1187
+ <View style={{ paddingHorizontal: 24, paddingBottom: 20, gap: 16 }}>
1188
+ {/* the hero: AI reads the files — no manual step */}
1189
+ <FileDropzone
1190
+ label="Drop the customer's files"
1191
+ hint="Orders, invoices, spreadsheets AI reads them and drafts the records"
1192
+ onFiles={(fs) => {
1193
+ setFileNames(fs.length > 0 ? fs.map((f) => f.name) : ["orders-june.pdf", "manifest.xlsx"]);
1194
+ setPhase("analyze");
1195
+ }}
1196
+ />
1197
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
1198
+ <View style={{ flex: 1 }}><Divider /></View>
1199
+ <Text size="xs" color="muted">or enter manually</Text>
1200
+ <View style={{ flex: 1 }}><Divider /></View>
1201
+ </View>
1202
+ <View style={{ gap: 8 }}>
1203
+ <CardSelectItem accessibilityLabel="New export case" onPress={() => { setVariant("export"); setPhase("form"); }} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
1204
+ <Icon name="arrow-up" size={18} color={colors.zinc[700]} />
1205
+ <View style={{ flex: 1, gap: 2 }}>
1206
+ <Text size="sm" weight="semibold">Export case</Text>
1207
+ <Text size="xs" color="muted">Outbound paperwork — the record seeds the customs-out checklist.</Text>
1208
+ </View>
1209
+ </CardSelectItem>
1210
+ <CardSelectItem accessibilityLabel="New import case" onPress={() => { setVariant("import"); setPhase("form"); }} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
1211
+ <Icon name="arrow-down" size={18} color={colors.zinc[700]} />
1212
+ <View style={{ flex: 1, gap: 2 }}>
1213
+ <Text size="sm" weight="semibold">Import case</Text>
1214
+ <Text size="xs" color="muted">Inbound paperwork — the record seeds the customs-in checklist.</Text>
1215
+ </View>
1216
+ </CardSelectItem>
1217
+ </View>
1218
+ </View>
1219
+ ) : null}
1220
+
1221
+ {phase === "analyze" || phase === "running" ? (
1222
+ // Streams follow in a FollowScroll (padding mirrors DialogScrollArea);
1223
+ // the phase flip swaps the scroller out, so the next pane opens at the top.
1224
+ <FollowScroll contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: small ? 16 : 24 }}>
1225
+ <AgentRun parts={script.slice(0, revealed)} state={revealed >= script.length ? "done" : "streaming"} />
1226
+ </FollowScroll>
1227
+ ) : null}
1228
+
1229
+ {phase === "clarify" ? (
1230
+ <View style={{ paddingHorizontal: 24, paddingBottom: 20 }}>
1231
+ <ClarifyWizard
1232
+ questions={INTAKE_QUESTIONS}
1233
+ onCancel={() => setPhase("intake")}
1234
+ onSubmit={(answers: ClarifyWizardAnswer[]) => {
1235
+ setGrouping(answers[0]?.value === "order" ? "order" : "customer");
1236
+ setPhase("running");
1237
+ }}
1238
+ />
1239
+ </View>
1240
+ ) : null}
1241
+
1242
+ {phase === "review" ? (
1243
+ <DialogScrollArea>
1244
+ <View style={{ gap: 8 }}>
1245
+ <ChangeReviewHeader title={`${proposals.length} records drafted`} />
1246
+ {proposals.map((p) => (
1247
+ <ChangeRecord
1248
+ key={p.id}
1249
+ id={p.id}
1250
+ tone="add"
1251
+ title={grouping === "customer" ? p.khach : `${p.orders} — ${p.khach}`}
1252
+ summary={`${p.orders}${p.dienThoai ? ` · ${p.dienThoai}` : ""}`}
1253
+ status={cardStatus[p.id] ?? "pending"}
1254
+ onAccept={() => setCardStatus((s) => ({ ...s, [p.id]: "accepted" }))}
1255
+ onReject={() => setCardStatus((s) => ({ ...s, [p.id]: "rejected" }))}
1256
+ onUndo={() => setCardStatus((s) => ({ ...s, [p.id]: "pending" }))}
1257
+ >
1258
+ <ChangeField label="Customer" value={p.khach} summary={p.khach} />
1259
+ {p.dienThoai ? <ChangeField label="Phone" value={p.dienThoai} summary={p.dienThoai} /> : null}
1260
+ <ChangeField label="Service fee" value={fees[p.id] ?? String(p.phi)} summary={formatMoney(Number(fees[p.id] ?? p.phi) || 0)}>
1261
+ <ChangeValueInput value={fees[p.id] ?? String(p.phi)} onChangeText={(v) => setFees((s) => ({ ...s, [p.id]: v }))} accessibilityLabel={`Service fee for ${p.khach}`} />
1262
+ </ChangeField>
1263
+ </ChangeRecord>
1264
+ ))}
1265
+ </View>
1266
+ </DialogScrollArea>
1267
+ ) : null}
1268
+
1269
+ {phase === "form" ? (
1270
+ <View style={{ paddingHorizontal: 24, paddingBottom: 12, gap: 12 }}>
1271
+ <Text size="xs" color="muted">
1272
+ {variant === "export"
1273
+ ? "The export case starts in Processing — details, tasks, and payment live on the record itself."
1274
+ : "The import case starts in Processing — details, tasks, and payment live on the record itself."}
1275
+ </Text>
1276
+ <FormField label="Customer">
1277
+ {khach === "" ? (
1278
+ /* find-or-create: the input stays a pure search (reflectSelection
1279
+ off — a custom "create" pick has no option to reflect anyway);
1280
+ the pick renders BELOW as the attached line with Change */
1281
+ <Combobox
1282
+ options={CUSTOMER_OPTIONS}
1283
+ onValueChange={(opt) => setKhach(opt.label ?? opt.value)}
1284
+ reflectSelection={false}
1285
+ allowCustom
1286
+ customOptionPlacement="top"
1287
+ customOptionLabel={(q) => `Create new customer “${q}”`}
1288
+ >
1289
+ <ComboboxInput icon="search" placeholder="Search customers, or add a new one…" accessibilityLabel="Customer" />
1290
+ <ComboboxContent />
1291
+ </Combobox>
1292
+ ) : (
1293
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, minHeight: 40 }}>
1294
+ <Icon name={CUSTOMER_OPTIONS.some((o) => o.label === khach) ? "building-2" : "plus"} size={16} color={colors.zinc[400]} />
1295
+ <Text size="sm" weight="medium" style={{ flex: 1 }} numberOfLines={1}>
1296
+ {khach}
1297
+ {CUSTOMER_OPTIONS.some((o) => o.label === khach) ? "" : " · new customer"}
1298
+ </Text>
1299
+ <Link size="sm" onPress={() => setKhach("")} accessibilityLabel="Change customer">Change</Link>
1300
+ </View>
1301
+ )}
1302
+ </FormField>
1303
+ <FormField label="Phone" optional optionalLabel="Optional">
1304
+ <TextInputField value={dienThoai} onChangeText={setDienThoai} placeholder="(555) 000-0000" accessibilityLabel="Phone" />
1305
+ </FormField>
1306
+ <FormField label="Service fee">
1307
+ <NumberInput value={phi} onValueChange={setPhi} min={0} accessibilityLabel="Service fee" />
1308
+ </FormField>
1309
+ </View>
1310
+ ) : null}
1311
+
1312
+ {phase === "form" ? (
1313
+ <DialogFooter>
1314
+ <Button title="Back" color="secondary" onPress={() => { setKhach(""); setDienThoai(""); setPhi(null); setPhase("intake"); }} />
1315
+ <Button title="Create record" color="primary" disabled={khach.trim() === ""} onPress={createManual} />
1316
+ </DialogFooter>
1317
+ ) : phase === "review" ? (
1318
+ <DialogFooter>
1319
+ <ChangeReviewActions onDiscard={close} onApply={applyProposals} applyLabel={`Add ${keptCount} ${keptCount === 1 ? "record" : "records"}`} />
1320
+ </DialogFooter>
1321
+ ) : null}
1092
1322
  </Dialog>
1323
+ </ChangeReview>
1093
1324
  </>
1094
1325
  );
1095
1326
  }
@@ -1119,7 +1350,7 @@ export function TplItemList() {
1119
1350
  // EMPTY; the commons wait as ghost suggestions).
1120
1351
  const [rows, setRows] = useState<HoSo[]>(HO_SO);
1121
1352
  const nextMa = useRef(64);
1122
- const createRecord = (khach: string, dienThoai: string, phi: number) => {
1353
+ const addRow = (khach: string, dienThoai: string, phi: number): string => {
1123
1354
  const now = new Date();
1124
1355
  const ma = `RC-2026-${String(nextMa.current++).padStart(4, "0")}`;
1125
1356
  const row: HoSo = {
@@ -1134,16 +1365,30 @@ export function TplItemList() {
1134
1365
  };
1135
1366
  setRows((prev) => [row, ...prev]);
1136
1367
  setTaskMap((prev) => ({ ...prev, [ma]: [] }));
1137
- // Reveal what was just created: clear every filter that could hide the
1138
- // new row, land on page 1, open its drawer.
1368
+ return ma;
1369
+ };
1370
+ // Reveal what was just created: clear every filter that could hide the new
1371
+ // row(s) and land on page 1.
1372
+ const revealNew = () => {
1139
1373
  setTab("all");
1140
1374
  setSearch("");
1141
1375
  setAssignee([]);
1142
1376
  setFeeStatus(null);
1143
1377
  setSort(null);
1144
1378
  setPage(0);
1379
+ };
1380
+ const createRecord = (khach: string, dienThoai: string, phi: number) => {
1381
+ const ma = addRow(khach, dienThoai, phi);
1382
+ revealNew();
1383
+ // A single manual create opens its drawer for refinement (create-then-refine).
1145
1384
  setOpenMa(ma);
1146
1385
  };
1386
+ // The AI-import apply — rows land at the top of the register; no drawer (a
1387
+ // bulk landing is reviewed IN the register, refined record by record later).
1388
+ const createRecordsBulk = (records: { khach: string; dienThoai: string; phi: number }[]) => {
1389
+ records.forEach((r) => addRow(r.khach, r.dienThoai, r.phi));
1390
+ revealNew();
1391
+ };
1147
1392
  const [sort, setSort] = useState<SortState | null>(null);
1148
1393
  const [assignee, setAssignee] = useState<string[]>([]);
1149
1394
  const [feeStatus, setFeeStatus] = useState<"paid" | "unpaid" | null>(null);
@@ -1285,7 +1530,7 @@ export function TplItemList() {
1285
1530
  onChange={(v) => { setFeeAmount(v); setPage(0); }}
1286
1531
  />
1287
1532
  </View>
1288
- <CreateRecordDialog onCreate={createRecord} />
1533
+ <EnterDataDialog onCreate={createRecord} onCreateMany={createRecordsBulk} />
1289
1534
  </View>
1290
1535
 
1291
1536
  {/* light summary of the filtered register (search/assignee/fee applied; status counts stay visible across tabs) */}
@@ -66,6 +66,8 @@ import { useSelection } from "@lotics/ui/use_selection";
66
66
  import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
67
67
  import { CardSelectItem } from "@lotics/ui/card_select_item";
68
68
  import { AgentRun } from "@lotics/ui/agent_run";
69
+ import { FollowScroll } from "@lotics/ui/follow_scroll";
70
+ import { useScreenSize } from "@lotics/ui/use_screen_size";
69
71
  import { type SourceRef } from "@lotics/ui/sources";
70
72
  import { ChangeValueInput, Change, ChangeField, ChangeFields, ChangeReasoning, ChangeRecord, ChangeReview, ChangeReviewActions, ChangeReviewHeader, type ChangeStatus } from "@lotics/ui/change_review";
71
73
  import { CompletionState } from "@lotics/ui/completion_state";
@@ -92,9 +94,8 @@ type Part = UIMessagePart<UIDataTypes, UITools>;
92
94
  // · Comments — the discussion thread. When people collaborate here.
93
95
  // · Tasks — the working checklist. Multi-step records with owners.
94
96
  // · Documents — the INTAKE desk: files that ARRIVE + the ONE "Use AI"
95
- // fork (shared with tpl_documents change BOTH; the one
96
- // divergence: generation lives in the output sections
97
- // here, in the toolbar dialog there).
97
+ // fork (the Agents "Document desk" pattern this template
98
+ // is its worked example).
98
99
  // · Customer — the LINKED PARTY: another record referenced, never
99
100
  // edited here (the linked-record box + drawer).
100
101
  // · Fees — the MONEY LEDGER: cost/charge lines, both directions.
@@ -429,11 +430,10 @@ const GUTTER = RAIL_W + RAIL_GAP;
429
430
  /** The reading column's ceiling. */
430
431
  const CONTENT_MAX = 720;
431
432
 
432
- // ── the DOCUMENT DESK — the Agents "Document desk" pattern (tpl_documents),
433
- // carried as the record's documents surface: files feed ONE "Use AI" entry
434
- // that forks into extract / cross-check / edit-with-AI. The two templates
435
- // share this desk — change it in BOTH (the one divergence: generation lives
436
- // in this page's output sections, in tpl_documents' toolbar dialog).
433
+ // ── the DOCUMENT DESK — the Agents "Document desk" pattern, carried as the
434
+ // record's documents surface: files feed ONE "Use AI" entry that forks into
435
+ // extract / cross-check / edit-with-AI; generation lives in this page's
436
+ // output sections (the desk itself is intake-only).
437
437
  type ScriptStep = { id: string; toolName: string; input?: unknown; output?: unknown };
438
438
  type Task = "extract" | "check";
439
439
  type Phase = "fork" | "running" | "review" | "done";
@@ -701,6 +701,7 @@ const DESTINATIONS: PickerOption<string, { country: string }>[] = [
701
701
  export function TplRecord() {
702
702
  const id = useRef(1);
703
703
  const nextId = (prefix: string) => `${prefix}_${(id.current += 1)}`;
704
+ const { small } = useScreenSize();
704
705
 
705
706
  // ── first paint = Skeleton MIRRORING the final layout, never a spinner.
706
707
  // The 700ms mock stands in for the record query a real app awaits.
@@ -835,8 +836,8 @@ export function TplRecord() {
835
836
  const taxId = customer?.taxId ?? "";
836
837
  const taxIdValid = TAX_ID_RE.test(taxId);
837
838
 
838
- // ── the document desk (carried verbatim from tpl_documents see the module
839
- // banner above; the record's files ARE the desk's register)
839
+ // ── the document desk (see the module banner above; the record's files ARE
840
+ // the desk's register)
840
841
  const [files, setFiles] = useState<Doc[]>(DOCS);
841
842
  const sel = useSelection();
842
843
 
@@ -1885,8 +1886,7 @@ export function TplRecord() {
1885
1886
  </View>
1886
1887
  <View style={{ flex: 1 }} />
1887
1888
  {/* no Create here — GENERATION lives in the Document set section
1888
- below (this desk = intake); tpl_documents, whose page has no
1889
- output section, keeps the dialog flavor in this slot */}
1889
+ below (this desk = intake) */}
1890
1890
  <Button
1891
1891
  title="Add files"
1892
1892
  color="secondary"
@@ -2719,7 +2719,7 @@ export function TplRecord() {
2719
2719
  </View>
2720
2720
  </ScrollView>
2721
2721
 
2722
- {/* ── the document desk's overlays (verbatim from tpl_documents) */}
2722
+ {/* ── the document desk's overlays */}
2723
2723
  <FloatingActionBar count={sel.count} label={sel.count === 1 ? "file selected" : "files selected"} onClear={sel.clear}>
2724
2724
  <Button title="Remove" color="danger-secondary" icon="trash" onPress={removeSelected} />
2725
2725
  <Button title="Download" color="secondary" icon="download" onPress={() => { /* a real app zips or opens each selected file (openExternal) */ }} />
@@ -2734,6 +2734,16 @@ export function TplRecord() {
2734
2734
  <DialogHeader>
2735
2735
  <DialogHeaderTitle>{task === "extract" ? "Extract data" : task === "check" ? "Cross-check" : `Use AI · ${picked.length} ${picked.length === 1 ? "file" : "files"}`}</DialogHeaderTitle>
2736
2736
  </DialogHeader>
2737
+ {phase === "running" ? (
2738
+ // The streaming feed gets its OWN scroller: FollowScroll (the chat list's
2739
+ // inverted mechanism) keeps the newest part in view as the run grows — a
2740
+ // plain DialogScrollArea would let it stream below the fold. The swap back
2741
+ // on the phase flip REMOUNTS the scroll area, so review opens at the TOP.
2742
+ // Padding mirrors DialogScrollArea.
2743
+ <FollowScroll contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: small ? 16 : 24 }}>
2744
+ <AgentRun parts={runItems} state={revealed >= script.length ? "done" : "streaming"} />
2745
+ </FollowScroll>
2746
+ ) : (
2737
2747
  <DialogScrollArea>
2738
2748
  {phase === "fork" ? (
2739
2749
  <View style={{ gap: 16 }}>
@@ -2776,8 +2786,6 @@ export function TplRecord() {
2776
2786
  </View>
2777
2787
  ) : null}
2778
2788
 
2779
- {phase === "running" ? <AgentRun parts={runItems} state={revealed >= script.length ? "done" : "streaming"} /> : null}
2780
-
2781
2789
  {phase === "review" && task === "extract" ? (
2782
2790
  <View style={{ gap: 16 }}>
2783
2791
  <View style={{ gap: 8 }}>
@@ -2904,6 +2912,7 @@ export function TplRecord() {
2904
2912
  <CompletionState title={`${applyCount} ${applyCount === 1 ? "change" : "changes"} applied to ${code}`} summary="The files stay on the record — each updated field keeps its source document." />
2905
2913
  ) : null}
2906
2914
  </DialogScrollArea>
2915
+ )}
2907
2916
  {phase === "fork" ? (
2908
2917
  <DialogFooter>
2909
2918
  {uploadFlow ? <Button title="Save files only" color="muted" onPress={() => { commitUpload(); closeAi(); }} /> : <Button title="Cancel" color="muted" onPress={closeAi} />}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "14.1.0",
3
+ "version": "14.3.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -113,6 +113,7 @@
113
113
  "./menu_list_item": "./src/menu_list_item.tsx",
114
114
  "./pressable_highlight": "./src/pressable_highlight.tsx",
115
115
  "./pressable_row": "./src/pressable_row.tsx",
116
+ "./press_door": "./src/press_door.tsx",
116
117
  "./floating_action_bar": "./src/floating_action_bar.tsx",
117
118
  "./icon_button": "./src/icon_button.tsx",
118
119
  "./info_popover": "./src/info_popover.tsx",
@@ -2,6 +2,7 @@ import { Text, View } from "react-native";
2
2
  import { fontFamilySemiBold } from "./text_utils";
3
3
  import { Icon, type IconName } from "./icon";
4
4
  import { isVideoMimeType, isAudioMimeType } from "./mime";
5
+ import { FILE_BADGE_DEFAULT_WIDTH, fileBadgeHeight } from "./file_badge_fit";
5
6
 
6
7
  const VIDEO_COLOR = "#ea580c";
7
8
  const AUDIO_COLOR = "#db2777";
@@ -32,12 +33,14 @@ export function resolveMime(mimeType: string): { label: string; color: string }
32
33
  return MIME_MAP[mimeType] ?? DEFAULT_BADGE;
33
34
  }
34
35
 
35
- const DEFAULT_SIZE = 26;
36
+ const DEFAULT_SIZE = FILE_BADGE_DEFAULT_WIDTH;
36
37
 
37
38
  interface FileBadgeProps {
38
39
  /** Omit only with `placeholder` — a ghost slot has no resolved type yet. */
39
40
  mimeType?: string;
40
- /** Base width in pixels. Height, radii, font, and padding scale proportionally. Default: 26 */
41
+ /** Base WIDTH in pixels. Height, radii, font, and padding scale proportionally the
42
+ * badge is TALLER than it is wide (26 × 32 at the default), so a SQUARE slot takes the
43
+ * fitted width, not the slot side (`DocumentBadge` does that conversion). Default: 26 */
41
44
  size?: number;
42
45
  /** Show a "TMPL" overlay to distinguish templates from regular files. */
43
46
  isTemplate?: boolean;
@@ -58,7 +61,7 @@ function getMediaIcon(mimeType: string): IconName | undefined {
58
61
 
59
62
  export function FileBadge({ mimeType, size = DEFAULT_SIZE, isTemplate, placeholder }: FileBadgeProps) {
60
63
  const scale = size / DEFAULT_SIZE;
61
- const height = Math.round(32 * scale);
64
+ const height = fileBadgeHeight(size);
62
65
  const radius = Math.round(4 * scale);
63
66
 
64
67
  if (placeholder) {
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import {
3
+ FILE_BADGE_BOX,
4
+ FILE_BADGE_DEFAULT_WIDTH,
5
+ fileBadgeHeight,
6
+ fileBadgeWidthForBox,
7
+ } from "./file_badge_fit";
8
+
9
+ describe("FileBadge geometry", () => {
10
+ it("scales the badge with the requested width instead of pinning the default", () => {
11
+ // The bug this covers: a caller's `size` was dropped and every badge rendered at
12
+ // the 26x32 default. A requested width must produce a proportionally smaller badge.
13
+ expect(fileBadgeHeight(FILE_BADGE_DEFAULT_WIDTH)).toBe(FILE_BADGE_BOX);
14
+ expect(fileBadgeHeight(20)).toBe(25);
15
+ expect(fileBadgeHeight(13)).toBe(16);
16
+ expect(fileBadgeHeight(52)).toBe(64);
17
+ });
18
+
19
+ it("fills the compact thumbnail slot exactly at the default width", () => {
20
+ // Why DEFAULT_WIDTH is 26: a default badge is exactly as tall as the 32px compact
21
+ // tile it sits in. A slot of 32 must therefore resolve to the default, unchanged.
22
+ expect(fileBadgeWidthForBox(FILE_BADGE_BOX)).toBe(FILE_BADGE_DEFAULT_WIDTH);
23
+ });
24
+
25
+ it("resolves a SMALLER slot to a smaller badge, not the default", () => {
26
+ // The reported bug: a sub-32 tile still rendered the 26x32 default — wider AND taller
27
+ // than its own tile. The requested slot now drives the badge.
28
+ expect(fileBadgeWidthForBox(30)).toBe(24);
29
+ expect(fileBadgeHeight(fileBadgeWidthForBox(30))).toBe(30);
30
+ expect(fileBadgeWidthForBox(24)).toBe(19);
31
+ expect(fileBadgeHeight(fileBadgeWidthForBox(24))).toBe(23);
32
+ });
33
+
34
+ it("never overflows its square slot, at any slot size", () => {
35
+ // The badge is taller than it is wide, so height is the binding dimension and the
36
+ // rounding in `fileBadgeHeight` can push a naive floor over the edge.
37
+ for (let box = 8; box <= 200; box++) {
38
+ const width = fileBadgeWidthForBox(box);
39
+ expect(width).toBeLessThanOrEqual(box);
40
+ expect(fileBadgeHeight(width)).toBeLessThanOrEqual(box);
41
+ }
42
+ });
43
+
44
+ it("grows monotonically with the slot", () => {
45
+ for (let box = 9; box <= 200; box++) {
46
+ expect(fileBadgeWidthForBox(box)).toBeGreaterThanOrEqual(fileBadgeWidthForBox(box - 1));
47
+ }
48
+ });
49
+ });