@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.
- package/AGENTS.md +12 -28
- package/MIGRATION.md +66 -0
- package/docs/ai_patterns.md +38 -16
- package/docs/catalog.md +71 -14
- package/docs/data_entry.md +53 -2
- package/docs/templates.md +15 -2
- package/examples/tpl_item_list.tsx +146 -48
- package/examples/tpl_record.tsx +37 -12
- package/package.json +13 -1
- package/src/agent_progress.tsx +11 -7
- package/src/agent_run.tsx +114 -43
- package/src/agent_transform.test.ts +96 -0
- package/src/agent_transform.ts +17 -5
- package/src/approval_prompt.tsx +70 -0
- package/src/dom_region.ts +14 -0
- package/src/dom_region.web.ts +13 -0
- package/src/file_drop_target.tsx +16 -0
- package/src/file_drop_target.web.tsx +118 -0
- package/src/file_dropzone.tsx +51 -96
- package/src/file_intake.test.ts +124 -0
- package/src/file_intake.ts +148 -0
- package/src/locale.tsx +21 -8
- package/src/message_actions.tsx +111 -0
- package/src/use_paste_files.ts +14 -0
- package/src/use_paste_files.web.ts +101 -0
|
@@ -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
|
-
|
|
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)
|
|
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 [
|
|
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
|
-
//
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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 = () => {
|
|
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
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
<
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
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
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
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
|
-
|
|
1222
|
-
|
|
1223
|
-
</
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/examples/tpl_record.tsx
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
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": "
|
|
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": {
|
package/src/agent_progress.tsx
CHANGED
|
@@ -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
|
|
22
|
-
*
|
|
23
|
-
|
|
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,
|
|
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,
|
|
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}
|
|
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}
|