@lotics/ui 14.3.1 → 15.1.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.
@@ -57,6 +57,7 @@ import { FollowScroll } from "@lotics/ui/follow_scroll";
57
57
  import { ClarifyWizard, type ClarifyWizardAnswer, type ClarifyWizardQuestion } from "@lotics/ui/clarify_wizard";
58
58
  import { CardSelectItem } from "@lotics/ui/card_select_item";
59
59
  import { FileDropzone } from "@lotics/ui/file_dropzone";
60
+ import { FileDropTarget } from "@lotics/ui/file_drop_target";
60
61
  import { ChangeField, ChangeRecord, ChangeReview, ChangeReviewActions, ChangeReviewHeader, ChangeValueInput, type ChangeStatus } from "@lotics/ui/change_review";
61
62
  import { useScreenSize } from "@lotics/ui/use_screen_size";
62
63
  import { MemberSelect } from "@lotics/ui/member_select";
@@ -461,6 +462,11 @@ function HoSoWorkspace({ hs, daThu, onPaid, tasks, onTasksChange, suggestions, o
461
462
  const [declared, setDeclared] = useState<number | null>(isSeed ? 25_000_000 : null);
462
463
  const [tags, setTags] = useState<string[]>(isSeed ? (hs.trangThai === "hoantat" ? ["corporate"] : ["corporate", "courier"]) : []);
463
464
  const [files, setFiles] = useState<DisplayFile[]>(isSeed ? [sampleDoc("id-scan", "#3b82f6"), sampleDoc("application-form", "#10b981")] : []);
465
+ // THE standard files intake — one handler, three ways in: the section's Add
466
+ // CTA, a drag anywhere onto the Files section, and Ctrl/Cmd+V — the last two
467
+ // both ride the section's `<FileDropTarget paste>` below. Every record surface
468
+ // carries all three; the user should never have to hunt for a dropzone.
469
+ const addFiles = (picked: File[]) => setFiles((prev) => [...prev, ...toDisplayFiles(picked)]);
464
470
  // ── the case's money: CHARGES owed vs RECEIPTS received — outstanding is
465
471
  // the difference. Recording a payment appends a receipt; everything
466
472
  // (bar, group sums, the closing number) recomputes from the two lists.
@@ -688,17 +694,23 @@ function HoSoWorkspace({ hs, daThu, onPaid, tasks, onTasksChange, suggestions, o
688
694
  <Divider />
689
695
  {/* FILES — the document CRUD: FileRows (press → gallery preview, ⋯ →
690
696
  remove) + the section's OWN CTA; an EXPECTED document renders as a
691
- ghost placeholder row with its own chase action. */}
697
+ ghost placeholder row with its own chase action.
698
+ The whole section is a FileDropTarget with `paste` — drag a file
699
+ anywhere onto it, or Ctrl/Cmd+V while focus is in it — the standard
700
+ files-intake behavior every record surface should carry. */}
701
+ <FileDropTarget onFiles={addFiles} paste>
692
702
  <Section>
693
703
  <SectionHeading>
694
- <SectionHeadingTitle>Files</SectionHeadingTitle>
704
+ {/* The standard files-section affordance line — drag / paste / click
705
+ are otherwise invisible on a FileDropTarget-wrapped section. */}
706
+ <SectionHeadingTitle description="Drag, paste, or click to add files">Files</SectionHeadingTitle>
695
707
  <SectionHeadingMeta>{`${files.length} ${files.length === 1 ? "file" : "files"}`}</SectionHeadingMeta>
696
708
  <Button
697
709
  title="Add file"
698
710
  color="secondary"
699
711
  onPress={() => {
700
712
  void pickFiles({ multiple: true }).then((picked) => {
701
- if (picked.length > 0) setFiles((prev) => [...prev, ...toDisplayFiles(picked)]);
713
+ if (picked.length > 0) addFiles(picked);
702
714
  });
703
715
  }}
704
716
  />
@@ -711,6 +723,7 @@ function HoSoWorkspace({ hs, daThu, onPaid, tasks, onTasksChange, suggestions, o
711
723
  trailing={<Button title="Request" color="muted" onPress={() => {}} />}
712
724
  />
713
725
  </Section>
726
+ </FileDropTarget>
714
727
  <Divider />
715
728
  {/* the PAYMENT summary — a plain financial STATEMENT: the two sides
716
729
  of the ledger (charges owed · receipts received), every amount on
@@ -1101,14 +1114,22 @@ const importScript = (grouping: "customer" | "order"): Part[] => [
1101
1114
  /** The manual gate stays the THREE-field create-then-refine form — past the
1102
1115
  * popover threshold (1–2 fields), so a Dialog pane; NOT a wizard: the record's
1103
1116
  * own workspace (the drawer it opens into) is where everything else is refined. */
1104
- function EnterDataDialog({ onCreate, onCreateMany }: {
1117
+ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany }: {
1118
+ /** Controlled open — the intake is driven by BOTH the toolbar "Enter data"
1119
+ * button (opens empty) AND the register's whole-surface FileDropTarget (opens
1120
+ * PRE-SEEDED via `seedDocs`), so the trigger lives with the parent. */
1121
+ open: boolean;
1122
+ onOpenChange: (open: boolean) => void;
1123
+ /** Files to pre-seed the intake WELL when the dialog opens from a top-level
1124
+ * drop/paste — a dropped pile lands as thumbnail cards (list-first, NOT
1125
+ * auto-analyze), ready for the deliberate "Read with AI". Empty from the button. */
1126
+ seedDocs: DisplayFile[];
1105
1127
  onCreate: (khach: string, dienThoai: string, phi: number) => void;
1106
1128
  onCreateMany: (records: { khach: string; dienThoai: string; phi: number }[]) => void;
1107
1129
  }) {
1108
1130
  const { small } = useScreenSize();
1109
- const [open, setOpen] = useState(false);
1110
1131
  const [phase, setPhase] = useState<IntakePhase>("intake");
1111
- const [fileNames, setFileNames] = useState<string[]>([]);
1132
+ const [docs, setDocs] = useState<DisplayFile[]>([]);
1112
1133
  const [variant, setVariant] = useState<ManualVariant>("export");
1113
1134
  const [grouping, setGrouping] = useState<"customer" | "order">("customer");
1114
1135
  // Q2's answer is load-bearing too: it decides how the unknown customer lands
@@ -1123,13 +1144,25 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
1123
1144
  const [dienThoai, setDienThoai] = useState("");
1124
1145
  const [phi, setPhi] = useState<number | null>(null);
1125
1146
 
1126
- // The stream reveal one revealer serves both the analyze and import phases.
1147
+ // Opening PRE-SEEDS the intake well: a top-level drop/paste passes the dropped
1148
+ // files via `seedDocs`, the button passes []. Keyed on `open` alone — the seed
1149
+ // is settled before the parent flips open, and re-seeding on every render would
1150
+ // fight the user's own add/remove among the thumbnails.
1151
+ useEffect(() => {
1152
+ if (open) {
1153
+ setPhase("intake");
1154
+ setDocs(seedDocs);
1155
+ }
1156
+ }, [open]);
1157
+
1158
+ // The analyze stream names the files it reads; the reveal serves analyze + import.
1159
+ const docNames = docs.map((d) => d.filename);
1127
1160
  const [revealed, setRevealed] = useState(0);
1128
- const script = phase === "analyze" ? analyzeScript(fileNames) : phase === "running" ? importScript(grouping) : [];
1161
+ const script = phase === "analyze" ? analyzeScript(docNames) : phase === "running" ? importScript(grouping) : [];
1129
1162
  useEffect(() => {
1130
1163
  if (phase !== "analyze" && phase !== "running") return;
1131
1164
  setRevealed(0);
1132
- const total = phase === "analyze" ? analyzeScript(fileNames).length : importScript(grouping).length;
1165
+ const total = phase === "analyze" ? analyzeScript(docNames).length : importScript(grouping).length;
1133
1166
  let n = 0;
1134
1167
  const t = setInterval(() => {
1135
1168
  n += 1;
@@ -1145,6 +1178,11 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
1145
1178
  // settled before their stream phase begins.
1146
1179
  }, [phase]);
1147
1180
 
1181
+ // ONE intake sink — every way IN adds to the pile: the whole-dialog
1182
+ // FileDropTarget (drag/paste anywhere on the open dialog), the big empty-state
1183
+ // dropzone, and the compact add-more dropzone all call this.
1184
+ const addDocs = (fs: File[]) => setDocs((prev) => [...prev, ...toDisplayFiles(fs)]);
1185
+
1148
1186
  const proposals = grouping === "customer" ? PROPOSED_BY_CUSTOMER : PROPOSED_BY_ORDER;
1149
1187
  // Kept = explicitly ACCEPTED (the kit's review contract): Apply stays disabled
1150
1188
  // at 0 kept, and the bar's own Keep-all presses every pending card's Keep.
@@ -1152,7 +1190,7 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
1152
1190
 
1153
1191
  const reset = () => {
1154
1192
  setPhase("intake");
1155
- setFileNames([]);
1193
+ setDocs([]);
1156
1194
  setCardStatus({});
1157
1195
  setFees({});
1158
1196
  setCustomerPlan("New customer created and linked");
@@ -1160,7 +1198,7 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
1160
1198
  setDienThoai("");
1161
1199
  setPhi(null);
1162
1200
  };
1163
- const close = () => { setOpen(false); reset(); };
1201
+ const close = () => { onOpenChange(false); reset(); };
1164
1202
 
1165
1203
  const applyProposals = () => {
1166
1204
  const kept = proposals.filter((p) => cardStatus[p.id] === "accepted");
@@ -1180,47 +1218,81 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
1180
1218
  : "Import from files";
1181
1219
 
1182
1220
  return (
1183
- <>
1184
- <Button title="Enter data" color="primary" onPress={() => setOpen(true)} />
1185
- <ChangeReview>
1186
- <Dialog width={phase === "review" ? 560 : 480} open={open} onOpenChange={(o) => { if (!o) close(); }}>
1221
+ <ChangeReview>
1222
+ {/* the file/stream phases (intake/analyze/clarify/running/review) run
1223
+ WIDE — room for the thumbnail hero + the AgentRun/ClarifyWizard/review
1224
+ streams; only the manual `form` stays a narrow single-column pane.
1225
+ 760 sits just under the kit Dialog's default 786 maxWidth cap. */}
1226
+ <Dialog width={phase === "form" ? 480 : 760} open={open} onOpenChange={(o) => { if (!o) close(); }}>
1187
1227
  <DialogHeader>
1188
1228
  <DialogHeaderTitle>{title}</DialogHeaderTitle>
1189
1229
  </DialogHeader>
1190
1230
 
1191
1231
  {phase === "intake" ? (
1192
- <View style={{ paddingHorizontal: 24, paddingBottom: 20, gap: 16 }}>
1193
- {/* the hero: AI reads the files no manual step */}
1194
- <FileDropzone
1195
- label="Drop the customer's files"
1196
- hint="Orders, invoices, spreadsheets AI reads them and drafts the records"
1197
- onFiles={(fs) => {
1198
- setFileNames(fs.length > 0 ? fs.map((f) => f.name) : ["orders-june.pdf", "manifest.xlsx"]);
1199
- setPhase("analyze");
1200
- }}
1201
- />
1202
- <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
1203
- <View style={{ flex: 1 }}><Divider /></View>
1204
- <Text size="xs" color="muted">or enter manually</Text>
1205
- <View style={{ flex: 1 }}><Divider /></View>
1206
- </View>
1207
- <View style={{ gap: 8 }}>
1208
- <CardSelectItem accessibilityLabel="New export case" onPress={() => { setVariant("export"); setPhase("form"); }} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
1209
- <Icon name="arrow-up" size={18} color={colors.zinc[700]} />
1210
- <View style={{ flex: 1, gap: 2 }}>
1211
- <Text size="sm" weight="semibold">Export case</Text>
1212
- <Text size="xs" color="muted">Outbound paperwork — the record seeds the customs-out checklist.</Text>
1232
+ // The WHOLE open dialog is a FileDropTarget: drag OR paste anywhere on
1233
+ // it adds files, in BOTH modes (the ring/wash frames the dialog on drag
1234
+ // — "drop anywhere here"). Nested with the dropzones below, which each
1235
+ // light their own well; stopPropagation keeps one drop from double-adding.
1236
+ <FileDropTarget onFiles={addDocs} paste style={{ paddingHorizontal: 24, paddingBottom: 20, gap: 16 }}>
1237
+ {/* ONE dialog, TWO MODES (not two Dialogs): FILES-PRESENT shows the
1238
+ pile + a compact add-more well + the deliberate Read; EMPTY shows
1239
+ the big dropzone hero + the manual chooser. Dropping in either ADDS
1240
+ files (via addDocs) — the paid AI read stays a DELIBERATE button,
1241
+ never a side effect of a drop. A pile means the user came to
1242
+ process files, so the manual cards fall away. */}
1243
+ {docs.length > 0 ? (
1244
+ <View style={{ gap: 16 }}>
1245
+ {/* the pile, as document cards — removable, the hero at top */}
1246
+ <FileThumbnailGrid
1247
+ files={docs}
1248
+ minItemWidth={120}
1249
+ onRemove={(id) => setDocs((prev) => prev.filter((d) => d.id !== id))}
1250
+ />
1251
+ {/* add-more is a real WELL (draggable + clickable), not a button —
1252
+ compact so the pile stays the hero */}
1253
+ <FileDropzone
1254
+ height={96}
1255
+ label="Drop more files"
1256
+ hint="or click to add"
1257
+ onFiles={addDocs}
1258
+ />
1259
+ <Button
1260
+ title={`Read ${docs.length} ${docs.length === 1 ? "file" : "files"} with AI`}
1261
+ color="primary"
1262
+ onPress={() => setPhase("analyze")}
1263
+ />
1264
+ </View>
1265
+ ) : (
1266
+ <>
1267
+ <FileDropzone
1268
+ label="Drop the customer's files"
1269
+ hint="Orders, invoices, spreadsheets — read them with AI"
1270
+ onFiles={addDocs}
1271
+ />
1272
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
1273
+ <View style={{ flex: 1 }}><Divider /></View>
1274
+ <Text size="xs" color="muted">or enter manually</Text>
1275
+ <View style={{ flex: 1 }}><Divider /></View>
1213
1276
  </View>
1214
- </CardSelectItem>
1215
- <CardSelectItem accessibilityLabel="New import case" onPress={() => { setVariant("import"); setPhase("form"); }} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
1216
- <Icon name="arrow-down" size={18} color={colors.zinc[700]} />
1217
- <View style={{ flex: 1, gap: 2 }}>
1218
- <Text size="sm" weight="semibold">Import case</Text>
1219
- <Text size="xs" color="muted">Inbound paperwork — the record seeds the customs-in checklist.</Text>
1277
+ <View style={{ gap: 8 }}>
1278
+ <CardSelectItem accessibilityLabel="New export case" onPress={() => { setVariant("export"); setPhase("form"); }} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
1279
+ <Icon name="arrow-up" size={18} color={colors.zinc[700]} />
1280
+ <View style={{ flex: 1, gap: 2 }}>
1281
+ <Text size="sm" weight="semibold">Export case</Text>
1282
+ <Text size="xs" color="muted">Outbound paperwork — the record seeds the customs-out checklist.</Text>
1283
+ </View>
1284
+ </CardSelectItem>
1285
+ <CardSelectItem accessibilityLabel="New import case" onPress={() => { setVariant("import"); setPhase("form"); }} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
1286
+ <Icon name="arrow-down" size={18} color={colors.zinc[700]} />
1287
+ <View style={{ flex: 1, gap: 2 }}>
1288
+ <Text size="sm" weight="semibold">Import case</Text>
1289
+ <Text size="xs" color="muted">Inbound paperwork — the record seeds the customs-in checklist.</Text>
1290
+ </View>
1291
+ </CardSelectItem>
1220
1292
  </View>
1221
- </CardSelectItem>
1222
- </View>
1223
- </View>
1293
+ </>
1294
+ )}
1295
+ </FileDropTarget>
1224
1296
  ) : null}
1225
1297
 
1226
1298
  {phase === "analyze" || phase === "running" ? (
@@ -1336,8 +1408,7 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
1336
1408
  </DialogFooter>
1337
1409
  ) : null}
1338
1410
  </Dialog>
1339
- </ChangeReview>
1340
- </>
1411
+ </ChangeReview>
1341
1412
  );
1342
1413
  }
1343
1414
 
@@ -1405,6 +1476,15 @@ export function TplItemList() {
1405
1476
  records.forEach((r) => addRow(r.khach, r.dienThoai, r.phi));
1406
1477
  revealNew();
1407
1478
  };
1479
+ // The intake dialog is CONTROLLED here so BOTH the toolbar button and the
1480
+ // whole-register FileDropTarget drive it: the button opens it empty, a
1481
+ // top-level drop/paste opens it PRE-SEEDED with the dropped files (mapped to
1482
+ // DisplayFiles so the pile shows as thumbnail cards — the paid "Read with AI"
1483
+ // stays a deliberate button).
1484
+ const [intakeOpen, setIntakeOpen] = useState(false);
1485
+ const [intakeSeed, setIntakeSeed] = useState<DisplayFile[]>([]);
1486
+ const openIntake = () => { setIntakeSeed([]); setIntakeOpen(true); };
1487
+ const openIntakeSeeded = (dropped: File[]) => { setIntakeSeed(toDisplayFiles(dropped)); setIntakeOpen(true); };
1408
1488
  const [sort, setSort] = useState<SortState | null>(null);
1409
1489
  const [assignee, setAssignee] = useState<string[]>([]);
1410
1490
  const [feeStatus, setFeeStatus] = useState<"paid" | "unpaid" | null>(null);
@@ -1475,6 +1555,17 @@ export function TplItemList() {
1475
1555
 
1476
1556
  return (
1477
1557
  <View style={{ flex: 1 }}>
1558
+ {/* WHOLE-SURFACE INTAKE — the standard this template DEMONSTRATES: the ENTIRE
1559
+ register page is one `FileDropTarget`, so a file dropped or a Ctrl/Cmd+V
1560
+ pasted ANYWHERE on the register opens the intake dialog PRE-SEEDED with
1561
+ those files (list-first — the paid "Read with AI" stays a button). No
1562
+ `disabled`: the intake's own inner `FileDropzone` and the record drawer's
1563
+ Files section are later-mounted paste sinks, so while either is open the
1564
+ paste routes THERE (top of stack); a nested drop is stopPropagation-safe.
1565
+ `flex:1` keeps the ScrollView filling the page (the bar + drawer overlay
1566
+ on top); the accent ring frames the page on drag. A real app opts in
1567
+ per-app — this page is the reference shape. */}
1568
+ <FileDropTarget onFiles={openIntakeSeeded} paste style={{ flex: 1 }}>
1478
1569
  <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28 }}>
1479
1570
  <View style={{ width: "100%", maxWidth: 1040, alignSelf: "center", gap: 16 }}>
1480
1571
  {/* header — title only; the New case action lives on the toolbar row */}
@@ -1546,7 +1637,9 @@ export function TplItemList() {
1546
1637
  onChange={(v) => { setFeeAmount(v); setPage(0); }}
1547
1638
  />
1548
1639
  </View>
1549
- <EnterDataDialog onCreate={createRecord} onCreateMany={createRecordsBulk} />
1640
+ {/* the intake TRIGGER — the register's whole-surface drop/paste opens
1641
+ the same dialog (see the FileDropTarget wrapper below) */}
1642
+ <Button title="Enter data" color="primary" onPress={openIntake} />
1550
1643
  </View>
1551
1644
 
1552
1645
  {/* light summary of the filtered register (search/assignee/fee applied; status counts stay visible across tabs) */}
@@ -1603,6 +1696,11 @@ export function TplItemList() {
1603
1696
  </View>
1604
1697
  </View>
1605
1698
  </ScrollView>
1699
+ </FileDropTarget>
1700
+
1701
+ {/* the intake dialog — CONTROLLED, driven by the toolbar button AND the
1702
+ whole-register drop/paste above (opened seeded with the dropped files) */}
1703
+ <EnterDataDialog open={intakeOpen} onOpenChange={setIntakeOpen} seedDocs={intakeSeed} onCreate={createRecord} onCreateMany={createRecordsBulk} />
1606
1704
 
1607
1705
  {/* bulk action over the ticked cases — the shared floating bar */}
1608
1706
  <FloatingActionBar
@@ -54,6 +54,7 @@ import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
54
54
  import { DangerZone } from "@lotics/ui/danger_zone";
55
55
  import { FileRow } from "@lotics/ui/file_row";
56
56
  import { pickFiles } from "@lotics/ui/file_picker";
57
+ import { FileDropTarget } from "@lotics/ui/file_drop_target";
57
58
  import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
58
59
  import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
59
60
  import { Finding, FindingComparison } from "@lotics/ui/finding";
@@ -881,6 +882,20 @@ export function TplRecord() {
881
882
  const [fieldDecisions, setFieldDecisions] = useState<Record<string, "kept" | "dropped">>({});
882
883
 
883
884
  const openAi = () => { setPicked(files.filter((f) => sel.has(f.id))); setUploadFlow(false); setAiOpen(true); };
885
+ // THE standard files intake — one handler, three ways in: the Documents
886
+ // section's Add-files CTA, a drag anywhere onto the record, and Ctrl/Cmd+V.
887
+ // The last two ride the WHOLE-RECORD `<FileDropTarget paste>` that wraps the
888
+ // section stack (see the render) — a drop/paste ANYWHERE on the surface, not
889
+ // only the Documents section, lands here. Every record surface should carry
890
+ // all three; the user never hunts for a dropzone. All three land in the same
891
+ // PENDING fork dialog — saving stays a decision. The target is `disabled`
892
+ // while the dialog is open so a paste can't start a SECOND one.
893
+ const intakeFiles = (chosen: File[]) => {
894
+ if (chosen.length === 0) return;
895
+ setPicked(chosen.map((f, i) => ({ id: `added-${f.name}-${i}`, name: f.name, mimeType: f.type || "application/pdf", kind: (f.type || "application/pdf").split("/")[1]?.toUpperCase().slice(0, 4) ?? "FILE", sizeKB: Math.round(f.size / 1024), added: "just now", addedAt: 999 })));
896
+ setUploadFlow(true);
897
+ setAiOpen(true);
898
+ };
884
899
  // The upload path's save: pending picked files land on the record only here.
885
900
  const commitUpload = () => setFiles((fs) => [...fs, ...picked.filter((p2) => !fs.some((f) => f.id === p2.id))]);
886
901
  const closeAi = () => {
@@ -1405,6 +1420,16 @@ export function TplRecord() {
1405
1420
  </View>
1406
1421
  ) : (
1407
1422
  <>
1423
+ {/* WHOLE-SURFACE INTAKE — the standard this template DEMONSTRATES: the
1424
+ ENTIRE record body is one `FileDropTarget`, so a file dropped or a
1425
+ Ctrl/Cmd+V pasted ANYWHERE on the record (not just the Documents
1426
+ section) is captured and routed to `intakeFiles` — the pending fork
1427
+ dialog opens (extract vs save, a decision). `flex:1, minWidth:0`
1428
+ preserves the reading column's flex/scroll; `disabled` while the
1429
+ dialog is open so a paste can't start a SECOND one; the accent ring
1430
+ frames the whole column on drag ("drop anywhere here"). A real app
1431
+ author opts in per-app — the section stack is the reference shape. */}
1432
+ <FileDropTarget onFiles={intakeFiles} accept="application/pdf,image/*" paste disabled={aiOpen} style={{ flex: 1, minWidth: 0 }}>
1408
1433
  {/* the page column is a SectionStack — it owns the 56px beat + the
1409
1434
  hairline BETWEEN top-level blocks (a conditional block that renders
1410
1435
  null never leaves a stray divider) */}
@@ -1868,11 +1893,16 @@ export function TplRecord() {
1868
1893
  </View>
1869
1894
 
1870
1895
  {/* DOCUMENTS — the document desk: the register Table (search · Create
1871
- documents · Add files) whose selection feeds the Use-AI fork below. */}
1896
+ documents · Add files) whose selection feeds the Use-AI fork below.
1897
+ No FileDropTarget of its OWN — the WHOLE-RECORD target above already
1898
+ captures a drop/paste here (and everywhere else on the surface); this
1899
+ section keeps only its explicit Add-files CTA and the affordance line. */}
1872
1900
  <View onLayout={nav.register("documents")}>
1873
1901
  <Section>
1874
1902
  <SectionHeading>
1875
- <SectionHeadingTitle>Documents</SectionHeadingTitle>
1903
+ {/* The standard files-section affordance line — drag / paste / click
1904
+ stay discoverable even though the drop target is the whole record. */}
1905
+ <SectionHeadingTitle description="Drag, paste, or click to add files">Documents</SectionHeadingTitle>
1876
1906
  </SectionHeading>
1877
1907
  {/* toolbar — search LEFT, the CTAs RIGHT, one row (the register band). */}
1878
1908
  <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
@@ -1887,20 +1917,14 @@ export function TplRecord() {
1887
1917
  <View style={{ flex: 1 }} />
1888
1918
  {/* no Create here — GENERATION lives in the Document set section
1889
1919
  below (this desk = intake) */}
1920
+ {/* PENDING until the user chooses: saving is a decision the dialog
1921
+ asks for (save only / run a task), never a side effect of
1922
+ picking — closing the dialog discards. */}
1890
1923
  <Button
1891
1924
  title="Add files"
1892
1925
  color="secondary"
1893
1926
  onPress={() => {
1894
- void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then((chosen) => {
1895
- if (chosen.length === 0) return;
1896
- const added = chosen.map((f, i) => ({ id: `added-${f.name}-${i}`, name: f.name, mimeType: f.type || "application/pdf", kind: (f.type || "application/pdf").split("/")[1]?.toUpperCase().slice(0, 4) ?? "FILE", sizeKB: Math.round(f.size / 1024), added: "just now", addedAt: 999 }));
1897
- // PENDING until the user chooses: saving is a decision the
1898
- // dialog asks for (save only / run a task), never a side
1899
- // effect of picking — closing the dialog discards.
1900
- setPicked(added);
1901
- setUploadFlow(true);
1902
- setAiOpen(true);
1903
- });
1927
+ void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then(intakeFiles);
1904
1928
  }}
1905
1929
  />
1906
1930
  </View>
@@ -2457,6 +2481,7 @@ export function TplRecord() {
2457
2481
  </Section>
2458
2482
  </View>
2459
2483
  </SectionStack>
2484
+ </FileDropTarget>
2460
2485
  </>
2461
2486
  )}
2462
2487
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "14.3.1",
3
+ "version": "15.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -21,6 +21,15 @@
21
21
  "./comments_thread": "./src/comments_thread.tsx",
22
22
  "./file_badge": "./src/file_badge.tsx",
23
23
  "./file_dropzone": "./src/file_dropzone.tsx",
24
+ "./file_intake": "./src/file_intake.ts",
25
+ "./file_drop_target": {
26
+ "react-native": "./src/file_drop_target.tsx",
27
+ "default": "./src/file_drop_target.web.tsx"
28
+ },
29
+ "./use_paste_files": {
30
+ "react-native": "./src/use_paste_files.ts",
31
+ "default": "./src/use_paste_files.web.ts"
32
+ },
24
33
  "./file_thumbnail": "./src/file_thumbnail.tsx",
25
34
  "./file_row": "./src/file_row.tsx",
26
35
  "./file_rows": "./src/file_rows.tsx",
@@ -80,6 +89,8 @@
80
89
  "./activity_indicator": "./src/activity_indicator.tsx",
81
90
  "./agent_run": "./src/agent_run.tsx",
82
91
  "./agent_progress": "./src/agent_progress.tsx",
92
+ "./approval_prompt": "./src/approval_prompt.tsx",
93
+ "./message_actions": "./src/message_actions.tsx",
83
94
  "./follow_scroll": "./src/follow_scroll.tsx",
84
95
  "./markdown": {
85
96
  "react-native": "./src/markdown.tsx",
@@ -234,6 +245,7 @@
234
245
  "src",
235
246
  "examples",
236
247
  "AGENTS.md",
248
+ "MIGRATION.md",
237
249
  "docs"
238
250
  ],
239
251
  "publishConfig": {
@@ -5,7 +5,7 @@ import { Text } from "./text";
5
5
  import { WaveAvatar } from "./wave_avatar";
6
6
  import { FollowScroll } from "./follow_scroll";
7
7
  import { PressableHighlight } from "./pressable_highlight";
8
- import { AgentRun, resolveToolMeta } from "./agent_run";
8
+ import { AgentRun, resolveToolMeta, type AgentRunProps, type AgentToolCall } from "./agent_run";
9
9
  import { toSegments, lastRunningStep, type AgentUIPart } from "./agent_transform";
10
10
 
11
11
  export interface AgentProgressProps {
@@ -18,9 +18,13 @@ export interface AgentProgressProps {
18
18
  /** The collapsed-pill label. Defaults to the running step's label (or
19
19
  * "Working…" / "Done"). */
20
20
  label?: string;
21
- /** Localize / override a tool step's label by raw tool name — applied to both
22
- * the collapsed pill and the expanded feed. */
23
- labelForTool?: (toolName: string) => string | undefined;
21
+ /** Localize / override a tool step's label from the CALL — applied to both the
22
+ * collapsed pill and the expanded feed. */
23
+ labelForCall?: (call: AgentToolCall) => string | undefined;
24
+ /** Replace a tool step's default output panel in the expanded feed. */
25
+ renderToolOutput?: AgentRunProps["renderToolOutput"];
26
+ /** Retry action under the expanded feed's terminal error row. */
27
+ onRetry?: () => void;
24
28
  /** Localized "{n} steps" suffix, forwarded to the expanded feed. */
25
29
  stepsLabel?: (n: number) => string;
26
30
  defaultExpanded?: boolean;
@@ -35,11 +39,11 @@ export interface AgentProgressProps {
35
39
  * label, so the surface stays calm until you ask to see the steps.
36
40
  */
37
41
  export function AgentProgress(props: AgentProgressProps) {
38
- const { parts, state = "streaming", error, label, labelForTool, stepsLabel, defaultExpanded } = props;
42
+ const { parts, state = "streaming", error, label, labelForCall, renderToolOutput, onRetry, stepsLabel, defaultExpanded } = props;
39
43
  const [expanded, setExpanded] = useState(defaultExpanded ?? false);
40
44
 
41
45
  const running = lastRunningStep(toSegments(parts));
42
- const runningLabel = running ? resolveToolMeta(running.toolName, labelForTool).label : undefined;
46
+ const runningLabel = running ? resolveToolMeta({ toolName: running.toolName, input: running.input, state: running.status }, labelForCall).label : undefined;
43
47
  const compact = label ?? runningLabel ?? (state === "done" ? "Done" : state === "error" ? "Stopped" : "Working…");
44
48
  const streaming = state === "streaming";
45
49
 
@@ -50,7 +54,7 @@ export function AgentProgress(props: AgentProgressProps) {
50
54
  {/* FollowScroll keeps the newest step in view as the run streams —
51
55
  a plain ScrollView would let new content grow below the fold. */}
52
56
  <FollowScroll style={{ maxHeight: 260 }} contentContainerStyle={{ padding: 16 }}>
53
- <AgentRun parts={parts} state={state} error={error} labelForTool={labelForTool} stepsLabel={stepsLabel} />
57
+ <AgentRun parts={parts} state={state} error={error} labelForCall={labelForCall} renderToolOutput={renderToolOutput} onRetry={onRetry} stepsLabel={stepsLabel} />
54
58
  </FollowScroll>
55
59
  </View>
56
60
  ) : null}