@lotics/ui 7.19.2 → 8.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,790 @@
1
+ import { Fragment, useEffect, useState } from "react";
2
+ import { ScrollView, View } from "react-native";
3
+ import { colors } from "@lotics/ui/colors";
4
+ import { Text } from "@lotics/ui/text";
5
+ import { Icon } from "@lotics/ui/icon";
6
+ import { Button } from "@lotics/ui/button";
7
+ import { Divider } from "@lotics/ui/divider";
8
+ import { Section, SectionHeading, SectionHeadingTitle } from "@lotics/ui/section_heading";
9
+ import { FileRow } from "@lotics/ui/file_row";
10
+ import { pickFiles } from "@lotics/ui/file_picker";
11
+ import { Alert } from "@lotics/ui/alert";
12
+ import { ActionMenu, type ActionMenuItem } from "@lotics/ui/action_menu";
13
+ import { Table, TableRow, TableCell, type TableColumn } from "@lotics/ui/table";
14
+ import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
15
+ import { FileBadge } from "@lotics/ui/file_badge";
16
+ import { Finding, FindingComparison } from "@lotics/ui/finding";
17
+ import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
18
+ import { type DisplayFile } from "@lotics/ui/file_thumbnail";
19
+ import { FormTextInput } from "@lotics/ui/form_text_input";
20
+ import { CheckboxInput } from "@lotics/ui/checkbox_input";
21
+ import { SearchInput } from "@lotics/ui/search_input";
22
+ import { useSelection } from "@lotics/ui/use_selection";
23
+ import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
24
+ import { Dialog, DialogHeader, DialogHeaderTitle, DialogScrollArea, DialogFooter } from "@lotics/ui/dialog";
25
+ import { CardSelectItem } from "@lotics/ui/card_select_item";
26
+ import { AgentRun, type AgentRunItem, type AgentRunStep } from "@lotics/ui/agent_run";
27
+ import { type SourceRef } from "@lotics/ui/sources";
28
+ import { ChangeValueInput, Change, ChangeField, ChangeFields, ChangeReasoning, ChangeRecord, ChangeReview, ChangeReviewActions, ChangeReviewHeader, type ChangeStatus } from "@lotics/ui/change_review";
29
+
30
+ import { CompletionState } from "@lotics/ui/completion_state";
31
+
32
+ // ─────────────────────────────────────────────────────────────────────────────
33
+ // Template · Document desk — THE worked example for everything document-driven
34
+ // on a record. A dossier's files block feeds ONE "Use AI" entry that FORKS into
35
+ // the two document tasks: EXTRACT (read the documents and fill the record —
36
+ // ONE `Change` section per RECORD whose body is `ChangeFields` stacking a
37
+ // `ChangeField` per proposed value: label · the − band when replacing · the
38
+ // editable + value. Editing IS the review; an add has no `before`, a removal
39
+ // is the − band alone, and a conflict shows the read-only outcome band over
40
+ // candidate rows + the type-another-value third option) and CROSS-CHECK
41
+ // (compare the documents against the record and each other — ranked
42
+ // display-only findings the human acts on), and EDIT WITH AI (the askAi handoff —
43
+ // dialogue-shaped file iteration happens in the Lotics chat; the fork is the ONE
44
+ // AI entry point, never per-row AI buttons). Plus CREATE DOCUMENTS: a
45
+ // readiness checklist → generate → the files land back on the record. All
46
+ // mock, useState + the timer that streams the AgentRun.
47
+ // ─────────────────────────────────────────────────────────────────────────────
48
+
49
+ type ScriptStep = Omit<AgentRunStep, "status">;
50
+ type Task = "extract" | "check";
51
+ type Phase = "fork" | "running" | "review" | "done";
52
+
53
+ interface Doc { id: string; name: string; mimeType: string; kind: string; sizeKB: number; added: string; addedAt: number }
54
+
55
+ // Display derives from the canonical numeric — strings would sort "8.4 MB" < "96 KB".
56
+ function fmtSize(kb: number): string {
57
+ if (kb <= 0) return "—";
58
+ return kb < 1024 ? `${kb} KB` : `${(kb / 1024).toFixed(1)} MB`;
59
+ }
60
+ const DOCS: Doc[] = [
61
+ { id: "f1", name: "invoice.pdf", mimeType: "application/pdf", kind: "PDF", sizeKB: 214, added: "26 Jun", addedAt: 626 },
62
+ { id: "f2", name: "packing-list.pdf", mimeType: "application/pdf", kind: "PDF", sizeKB: 96, added: "26 Jun", addedAt: 626 },
63
+ { id: "f3", name: "booking-confirmation.pdf", mimeType: "application/pdf", kind: "PDF", sizeKB: 182, added: "28 Jun", addedAt: 628 },
64
+ { id: "f4", name: "photos.zip", mimeType: "application/zip", kind: "ZIP", sizeKB: 8602, added: "30 Jun", addedAt: 630 },
65
+ ];
66
+
67
+ const EXTRACT_STEPS: ScriptStep[] = [
68
+ { id: "e1", label: "Reading the selected documents", detail: "Page by page, tables included" },
69
+ { id: "e2", label: "get_record", kind: "tool" },
70
+ { id: "e3", label: "Extracting field values", detail: "9 fields · 2 order lines" },
71
+ { id: "e4", label: "Comparing against the record", detail: "3 match · 1 new · 1 change · 1 conflict" },
72
+ ];
73
+ const CHECK_STEPS: ScriptStep[] = [
74
+ { id: "c1", label: "Reading the selected documents", detail: "Quantities, parties, dates and terms" },
75
+ { id: "c2", label: "get_record", kind: "tool" },
76
+ { id: "c3", label: "Cross-checking documents and record", detail: "18 fields compared across the sources" },
77
+ ];
78
+
79
+ // The shapes of an extract decision — an ADD, an UPDATE, a REMOVAL, a source
80
+ // CONFLICT — are the SAME `ChangeField` row: an add has no `before`, an update
81
+ // bands it, a removal is the − band alone, and a conflict's outcome is the
82
+ // read-only + band over the candidate rows.
83
+ const MBL_PROPOSED = "MAEU129394855";
84
+ const VESSEL_CURRENT = "MSC AURA";
85
+ const VESSEL_PROPOSED = "MAERSK SALINA";
86
+ const CONSIGNEE_CURRENT = "Nordic Furniture AB";
87
+ const NOTIFY_CURRENT = "Euro Textile Trading GmbH, Frankfurt";
88
+ const EXTRACT_REASONING = "The booking confirmation names the substitute vessel for this sailing week; the invoice and the packing list disagree on the consignee.";
89
+ const CONSIGNEE_OPTIONS = [
90
+ { value: "Nordic Furniture AB, Jönköping DC", source: "invoice.pdf", recommended: true },
91
+ { value: "NF Distribution ApS, Kolding", source: "packing-list.pdf" },
92
+ ];
93
+ const EXTRACT_FIELD_IDS = ["mbl", "vessel", "consignee", "gross", "notify"] as const;
94
+
95
+ // Cross-check findings — severity reads through ONE colored dot badge (red /
96
+ // amber / zinc), the rest stays calm text.
97
+ type Severity = "critical" | "warning" | "info";
98
+ interface Check { id: string; severity: Severity; title: string; detail?: string; comparison?: { values: { label: string; value: string }[]; delta?: string }; sources?: string[] }
99
+ const FINDINGS: Check[] = [
100
+ {
101
+ id: "q1", severity: "critical", title: "Quantity disagrees between the invoice and the packing list",
102
+ detail: "Short-shipping against the invoice risks a customs query and a client claim.",
103
+ comparison: { values: [{ label: "invoice.pdf", value: "480 pcs" }, { label: "packing-list.pdf", value: "440 pcs" }], delta: "−40 pcs" },
104
+ sources: ["invoice.pdf", "packing-list.pdf"],
105
+ },
106
+ {
107
+ id: "q2", severity: "warning", title: "Consignee differs between the invoice and the booking",
108
+ detail: "The invoice names the buyer's own warehouse; the booking routes delivery through a distribution partner. One of them files wrong.",
109
+ sources: ["invoice.pdf", "booking-confirmation.pdf"],
110
+ },
111
+ {
112
+ id: "q4", severity: "warning", title: "No certificate of origin among the documents",
113
+ detail: "Destination customs requires a C/O for this HS chapter — request it before the vessel cut-off.",
114
+ },
115
+ {
116
+ id: "q3", severity: "info", title: "Booking cut-off is earlier than the invoiced ship week",
117
+ detail: "Documents close 14 Jul on the booking; the invoice quotes shipment in the week of 18 Jul. No conflict if the cargo is ready.",
118
+ sources: ["booking-confirmation.pdf"],
119
+ },
120
+ ];
121
+
122
+ // A tiny REAL pdf (data URI) so the gallery preview genuinely renders.
123
+ const MOCK_PDF_URL = "data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iajw8L1R5cGUvQ2F0YWxvZy9QYWdlcyAyIDAgUj4+ZW5kb2JqCjIgMCBvYmo8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PmVuZG9iagozIDAgb2JqPDwvVHlwZS9QYWdlL1BhcmVudCAyIDAgUi9NZWRpYUJveFswIDAgNjEyIDc5Ml0vQ29udGVudHMgNCAwIFIvUmVzb3VyY2VzPDwvRm9udDw8L0YxIDUgMCBSPj4+Pj4+ZW5kb2JqCjQgMCBvYmo8PC9MZW5ndGggNjM+PnN0cmVhbQpCVCAvRjEgMTggVGYgNzIgNzIwIFRkIChOb3JkaWMgRnVybml0dXJlIC0gbW9jayBkb2N1bWVudCkgVGogRVQKZW5kc3RyZWFtIGVuZG9iago1IDAgb2JqPDwvVHlwZS9Gb250L1N1YnR5cGUvVHlwZTEvQmFzZUZvbnQvSGVsdmV0aWNhPj5lbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTIgMDAwMDAgbiAKMDAwMDAwMDEwMSAwMDAwMCBuIAowMDAwMDAwMjExIDAwMDAwIG4gCjAwMDAwMDAzMjAgMDAwMDAgbiAKdHJhaWxlcjw8L1NpemUgNi9Sb290IDEgMCBSPj4Kc3RhcnR4cmVmCjM4MQolJUVPRg==";
124
+ const toDisplay = (d: Doc): DisplayFile => ({ id: d.id, filename: d.name, mimeType: d.mimeType, url: MOCK_PDF_URL });
125
+
126
+ // The documents register — columns ONCE (the Table renders the header band
127
+ // and every cell width from these; facts get dedicated scannable columns
128
+ // instead of a crammed meta string). Same grammar as the case register
129
+ // (tpl_item_list): leading checkbox + select-all, trailing ⋯ gutter.
130
+ const DOC_COLUMNS: TableColumn[] = [
131
+ { key: "doc", label: "Document", flex: 1, sortable: true },
132
+ { key: "size", label: "Size", width: 90, align: "right", sortable: true },
133
+ { key: "added", label: "Added", width: 96, sortable: true },
134
+ ];
135
+
136
+ interface Producible { id: string; label: string; file: string; sizeKB: number; needs?: { key: string; label: string }[] }
137
+ const PRODUCIBLE: Producible[] = [
138
+ { id: "g1", label: "Shipping instruction", file: "shipping-instruction.pdf", sizeKB: 84 },
139
+ { id: "g2", label: "Certificate of origin", file: "certificate-of-origin.pdf", sizeKB: 61 },
140
+ { id: "g3", label: "Delivery note", file: "delivery-note.pdf", sizeKB: 72, needs: [{ key: "delivery_address", label: "Delivery address" }, { key: "hs_code", label: "HS code" }] },
141
+ { id: "g4", label: "Customs declaration draft", file: "customs-declaration-draft.pdf", sizeKB: 118 },
142
+ ];
143
+
144
+ export function TplDocuments() {
145
+ const [files, setFiles] = useState<Doc[]>(DOCS);
146
+ const sel = useSelection();
147
+
148
+ // Use AI — one entry off the selection, forking into the two document tasks.
149
+ const [aiOpen, setAiOpen] = useState(false);
150
+ const [picked, setPicked] = useState<Doc[]>([]);
151
+ const [brief, setBrief] = useState("");
152
+ // The fork SELECTS a task; the footer CTA runs it (select → confirm, never
153
+ // act-on-press for an analysis that costs a run).
154
+ const [taskChoice, setTaskChoice] = useState<Task | "edit" | null>(null);
155
+ // True when the dialog opened from a fresh upload — the fork then offers
156
+ // keeping the files without running anything.
157
+ const [uploadFlow, setUploadFlow] = useState(false);
158
+ // Full-page preview over whichever list the user was looking at.
159
+ const [preview, setPreview] = useState<{ files: DisplayFile[]; index: number } | null>(null);
160
+ const openPreview = (docs: Doc[], index: number) => setPreview({ files: docs.map(toDisplay), index });
161
+ const [task, setTask] = useState<Task | null>(null);
162
+ const [phase, setPhase] = useState<Phase>("fork");
163
+ const [revealed, setRevealed] = useState(0);
164
+ // The HOST owns the review: one editable proposal per field (editing IS the
165
+ // review), a tri-state decision per field (dropped rows collapse struck,
166
+ // out of the apply), and the conflict's pick (empty until the user chooses).
167
+ const [mbl, setMbl] = useState(MBL_PROPOSED);
168
+ const [vessel, setVessel] = useState(VESSEL_PROPOSED);
169
+ const [consignee, setConsignee] = useState<string | null>(null);
170
+ const [grossWeight, setGrossWeight] = useState("1,540");
171
+ // Order lines proposed from the packing list — RECORD ops (ChangeRecord).
172
+ const [lineAdd, setLineAdd] = useState<ChangeStatus>("pending");
173
+ const [lineEdit, setLineEdit] = useState<Record<string, "kept" | "dropped">>({});
174
+ const [newItem, setNewItem] = useState("Corner protectors, foam");
175
+ const [newQty, setNewQty] = useState("400");
176
+ const [editedQty, setEditedQty] = useState("1,450");
177
+ const lineEditRow = (id: string) => ({
178
+ status: lineEdit[id] ?? ("pending" as const),
179
+ onKeep: () => setLineEdit((m) => ({ ...m, [id]: "kept" as const })),
180
+ onDrop: () => setLineEdit((m) => ({ ...m, [id]: "dropped" as const })),
181
+ onUndo: () => setLineEdit((m) => { const n = { ...m }; delete n[id]; return n; }),
182
+ });
183
+ const [consigneePick, setConsigneePick] = useState<number | "custom" | null>(null);
184
+ const [customConsignee, setCustomConsignee] = useState("");
185
+ const [fieldDecisions, setFieldDecisions] = useState<Record<string, "kept" | "dropped">>({});
186
+
187
+ const openAi = () => { setPicked(files.filter((f) => sel.has(f.id))); setUploadFlow(false); setAiOpen(true); };
188
+ // The upload path's save: pending picked files land on the record only here.
189
+ const commitUpload = () => setFiles((fs) => [...fs, ...picked.filter((p2) => !fs.some((f) => f.id === p2.id))]);
190
+ const closeAi = () => {
191
+ setAiOpen(false); setTask(null); setPhase("fork"); setRevealed(0); setUploadFlow(false); setBrief(""); setTaskChoice(null);
192
+ setMbl(MBL_PROPOSED); setVessel(VESSEL_PROPOSED); setConsignee(null); setConsigneePick(null); setCustomConsignee(""); setGrossWeight("1,540"); setLineAdd("pending"); setLineEdit({}); setNewItem("Corner protectors, foam"); setNewQty("400"); setEditedQty("1,450"); setFieldDecisions({});
193
+ };
194
+ const startTask = (t: Task) => { setTask(t); setRevealed(0); setPhase("running"); };
195
+ // ⋯ menu per document row. "Edit with AI" is the app→chat handoff for
196
+ // dialogue-shaped work on THIS document — a real app calls the SDK:
197
+ // void askAi({ file_ids: [f.id], record_ids: [orderRecordId], prompt: `Update ${f.name} — ` });
198
+ // and the Lotics messenger opens a fresh chat with the file attached AND
199
+ // previewed beside it; the prompt is prefilled, editable, never auto-sent.
200
+ // Structured judgment that commits back into fields stays on the Use-AI
201
+ // review flow. Rename works for real; Download is host-served in an app
202
+ // (openExternal(file.url)).
203
+ // One sort at a time; the third toggle on a column clears it (register law).
204
+ const [docSort, setDocSort] = useState<SortState | null>(null);
205
+ const [docSearch, setDocSearch] = useState("");
206
+ const docQuery = docSearch.trim().toLowerCase();
207
+ const visibleFiles = sortBy(
208
+ docQuery ? files.filter((f) => f.name.toLowerCase().includes(docQuery)) : files,
209
+ docSort,
210
+ (f, key) => (key === "doc" ? f.name.toLowerCase() : key === "size" ? f.sizeKB : f.addedAt),
211
+ );
212
+
213
+ const [renameTarget, setRenameTarget] = useState<Doc | null>(null);
214
+ const [renameDraft, setRenameDraft] = useState("");
215
+ const openRename = (f: Doc) => { setRenameTarget(f); setRenameDraft(f.name); };
216
+ const saveRename = () => {
217
+ const name = renameDraft.trim();
218
+ if (renameTarget && name) setFiles((fs) => fs.map((x) => (x.id === renameTarget.id ? { ...x, name } : x)));
219
+ setRenameTarget(null);
220
+ };
221
+ const editWithAi = (docs: Doc[]) => {
222
+ const names = docs.map((d) => d.name).join(", ");
223
+ Alert.alert(
224
+ "Edit with AI",
225
+ `Opens the Lotics chat on a fresh thread with ${names} attached${docs.length === 1 ? " and previewed side by side" : ""} — describe the change and the agent edits as new versions.`,
226
+ [{ text: "OK" }],
227
+ );
228
+ };
229
+ const fileMenuFor = (f: Doc): ActionMenuItem[] => [
230
+ { key: "rename", label: "Rename", icon: "pencil", onPress: () => openRename(f) },
231
+ { key: "download", label: "Download", icon: "download", onPress: () => { /* a real app: openExternal(f.url) */ } },
232
+ ];
233
+
234
+ const removeSelected = () => {
235
+ Alert.alert(
236
+ `Remove ${sel.count} ${sel.count === 1 ? "file" : "files"}?`,
237
+ "They come off the order's documents — the originals stay wherever they came from.",
238
+ [
239
+ { text: "Keep files", style: "cancel" },
240
+ { text: "Remove", style: "destructive", onPress: () => { setFiles((fs) => fs.filter((f) => !sel.has(f.id))); sel.clear(); } },
241
+ ],
242
+ );
243
+ };
244
+ const decideField = (id: string, d: "kept" | "dropped" | undefined) =>
245
+ setFieldDecisions((m) => {
246
+ const n = { ...m };
247
+ if (d) n[id] = d;
248
+ else delete n[id];
249
+ return n;
250
+ });
251
+ const fieldRow = (id: string) => ({
252
+ status: fieldDecisions[id] ?? ("pending" as const),
253
+ onKeep: () => decideField(id, "kept"),
254
+ onDrop: () => decideField(id, "dropped"),
255
+ onUndo: () => decideField(id, undefined),
256
+ });
257
+
258
+ // The streaming timer idiom — reveal one script step at a time, then settle.
259
+ const script = task === "check" ? CHECK_STEPS : EXTRACT_STEPS;
260
+ useEffect(() => {
261
+ if (phase !== "running") return;
262
+ if (revealed >= script.length) {
263
+ const t = setTimeout(() => setPhase("review"), 850);
264
+ return () => clearTimeout(t);
265
+ }
266
+ const t = setTimeout(() => setRevealed((r) => r + 1), revealed === 0 ? 280 : 680);
267
+ return () => clearTimeout(t);
268
+ }, [phase, revealed, script.length]);
269
+
270
+ const runItems: AgentRunItem[] = [
271
+ { type: "text", id: "in", text: (task === "check" ? "Comparing the documents against the record and each other." + (brief.trim() ? ` Also: ${brief.trim()}` : "") : "Reading the documents and comparing them with the record.") },
272
+ ...script.slice(0, Math.min(revealed + 1, script.length)).map((s, i): AgentRunItem => ({
273
+ type: "step", ...s, detail: i < revealed ? s.detail : undefined, status: i < revealed ? "done" : "running",
274
+ })),
275
+ ];
276
+ if (revealed >= script.length) {
277
+ runItems.push({ type: "text", id: "out", text: task === "check" ? "Checked 18 fields — 2 disagree, 1 worth noting. The findings are below." : "3 fields already match; 3 need a decision, and the invoice carries 2 lines the order doesn't have yet." });
278
+ }
279
+
280
+ // Apply counts what actually commits: the kept fields.
281
+ const applyCount = Object.values(fieldDecisions).filter((d) => d === "kept").length + Object.values(lineEdit).filter((d) => d === "kept").length + (lineAdd === "accepted" ? 1 : 0);
282
+ // Keep-all lives with the HOST (field decisions the registry can't see):
283
+ // keep every still-pending field; the unresolved conflict stays pending.
284
+ const keepAll = () => {
285
+ setLineAdd((st) => (st === "pending" ? "accepted" : st));
286
+ setLineEdit((m) => ({ qty: m.qty ?? "kept", ...m }));
287
+ setFieldDecisions((m) => {
288
+ const n = { ...m };
289
+ for (const f of EXTRACT_FIELD_IDS) {
290
+ if (f === "consignee" && consignee == null) continue;
291
+ if (n[f] == null) n[f] = "kept";
292
+ }
293
+ return n;
294
+ });
295
+ };
296
+
297
+ // Create documents — a readiness checklist; generated files land on the record.
298
+ const gen = useSelection();
299
+ const [genOpen, setGenOpen] = useState(false);
300
+ // Required inputs some documents still miss — filled in the dialog's own
301
+ // fill screen, saved onto the record, readiness recomputes.
302
+ const [docFieldValues, setDocFieldValues] = useState<Record<string, string>>({});
303
+ const [fillTarget, setFillTarget] = useState<Producible | null>(null);
304
+ const [fillDraft, setFillDraft] = useState<Record<string, string>>({});
305
+ const missingOf = (p: Producible) => (p.needs ?? []).filter((n) => !docFieldValues[n.key]?.trim());
306
+ const openFill = (p: Producible) => {
307
+ setFillTarget(p);
308
+ setFillDraft(Object.fromEntries((p.needs ?? []).map((n) => [n.key, docFieldValues[n.key] ?? ""])));
309
+ };
310
+ const saveFill = () => {
311
+ setDocFieldValues((v) => ({ ...v, ...fillDraft }));
312
+ setFillTarget(null);
313
+ };
314
+ const [genDone, setGenDone] = useState(false);
315
+ // Generated documents stay PENDING until the user commits them — adding to
316
+ // the record is a decision (the same law as the upload flow), and the files
317
+ // are previewable while deciding.
318
+ const [genPending, setGenPending] = useState<Doc[]>([]);
319
+ // Generation is real work in a live app (template fill + render per
320
+ // document) — it runs as a visible scripted AgentRun, never a teleport.
321
+ const [genRunning, setGenRunning] = useState(false);
322
+ const [genRevealed, setGenRevealed] = useState(0);
323
+ const [genSteps, setGenSteps] = useState<{ id: string; label: string }[]>([]);
324
+ const generate = () => {
325
+ const chosen = PRODUCIBLE.filter((p) => gen.has(p.id));
326
+ setGenSteps([
327
+ { id: "read", label: "Reading the order's data" },
328
+ ...chosen.map((p) => ({ id: p.id, label: `Filling ${p.label}` })),
329
+ ]);
330
+ setGenPending(chosen.map((p) => ({ id: `gen-${p.id}`, name: p.file, mimeType: "application/pdf", kind: "PDF", sizeKB: p.sizeKB, added: "", addedAt: 0 })));
331
+ setGenRevealed(0);
332
+ setGenRunning(true);
333
+ };
334
+ useEffect(() => {
335
+ if (!genRunning) return;
336
+ if (genRevealed >= genSteps.length) {
337
+ const t = setTimeout(() => { setGenRunning(false); setGenDone(true); }, 600);
338
+ return () => clearTimeout(t);
339
+ }
340
+ const t = setTimeout(() => setGenRevealed((r) => r + 1), genRevealed === 0 ? 260 : 620);
341
+ return () => clearTimeout(t);
342
+ }, [genRunning, genRevealed, genSteps.length]);
343
+ const addGeneratedToRecord = () => {
344
+ setFiles((fs) => [...fs, ...genPending.map((g) => ({ ...g, added: "just now", addedAt: 999 }))]);
345
+ closeGen();
346
+ };
347
+ const closeGen = () => { setGenOpen(false); setGenDone(false); setGenRunning(false); setGenRevealed(0); setFillTarget(null); setGenPending([]); gen.clear(); };
348
+
349
+ return (
350
+ <>
351
+ <ScrollView style={{ flex: 1, backgroundColor: colors.white }} contentContainerStyle={{ padding: 28, paddingBottom: 120 }}>
352
+ <View style={{ maxWidth: 720, width: "100%", alignSelf: "center", gap: 24 }}>
353
+ <View style={{ gap: 2 }}>
354
+ <Text size="xl" weight="semibold">PO-2481 — Nordic Furniture AB</Text>
355
+ <Text size="sm" color="muted">Sea freight · Gothenburg → Ho Chi Minh City · FOB · ETD 18 Jul 2026</Text>
356
+ </View>
357
+
358
+ <Section>
359
+ <SectionHeading>
360
+ <SectionHeadingTitle>Documents</SectionHeadingTitle>
361
+ </SectionHeading>
362
+ {/* toolbar — search LEFT, the CTAs RIGHT, one row (the register band). */}
363
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
364
+ <View style={{ flexGrow: 1, flexBasis: 220, minWidth: 200, maxWidth: 340 }}>
365
+ <SearchInput
366
+ placeholder="Search documents…"
367
+ value={docSearch}
368
+ onChangeText={setDocSearch}
369
+ accessibilityLabel="Search documents"
370
+ />
371
+ </View>
372
+ <View style={{ flex: 1 }} />
373
+ <Button title="Create documents" color="muted" onPress={() => setGenOpen(true)} />
374
+ <Button
375
+ title="Add files"
376
+ color="secondary"
377
+ onPress={() => {
378
+ void pickFiles({ accept: "application/pdf,image/*", multiple: true }).then((chosen) => {
379
+ if (chosen.length === 0) return;
380
+ 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 }));
381
+ // PENDING until the user chooses: saving is a decision the
382
+ // dialog asks for (save only / run a task), never a side
383
+ // effect of picking — closing the dialog discards.
384
+ setPicked(added);
385
+ setUploadFlow(true);
386
+ setAiOpen(true);
387
+ });
388
+ }}
389
+ />
390
+ </View>
391
+ <Table
392
+ columns={DOC_COLUMNS}
393
+ leading={24}
394
+ trailing={44}
395
+ sort={docSort}
396
+ onSort={(key) => setDocSort(cycleSort(docSort, key))}
397
+ selectAll={
398
+ <CheckboxInput
399
+ accessibilityLabel="Select all documents"
400
+ checked={sel.allSelected(visibleFiles.map((f) => f.id))}
401
+ indeterminate={sel.indeterminate(visibleFiles.map((f) => f.id))}
402
+ onChange={(on) => sel.setAll(visibleFiles.map((f) => f.id), on)}
403
+ />
404
+ }
405
+ >
406
+ {visibleFiles.map((f, fi) => (
407
+ <TableRow
408
+ key={f.id}
409
+ onPress={() => openPreview(visibleFiles, fi)}
410
+ marked={sel.has(f.id)}
411
+ accessibilityLabel={`Open ${f.name}`}
412
+ leading={<CheckboxInput accessibilityLabel={`Select ${f.name}`} checked={sel.has(f.id)} onChange={(on) => sel.toggle(f.id, on)} />}
413
+ trailing={<ActionMenu items={fileMenuFor(f)} accessibilityLabel={`Actions for ${f.name}`} />}
414
+ >
415
+ <TableCell>
416
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 10 }}>
417
+ <FileBadge mimeType={f.mimeType} />
418
+ <Text size="sm" weight="medium" numberOfLines={1} style={{ flexShrink: 1 }}>{f.name}</Text>
419
+ </View>
420
+ </TableCell>
421
+ <TableCell>
422
+ <Text size="sm" tabular>{fmtSize(f.sizeKB)}</Text>
423
+ </TableCell>
424
+ <TableCell>
425
+ <Text size="sm" tabular>{f.added}</Text>
426
+ </TableCell>
427
+ </TableRow>
428
+ ))}
429
+ </Table>
430
+ </Section>
431
+ </View>
432
+ </ScrollView>
433
+
434
+ <FloatingActionBar count={sel.count} label={sel.count === 1 ? "file selected" : "files selected"} onClear={sel.clear}>
435
+ <Button title="Remove" color="danger-secondary" icon="trash" onPress={removeSelected} />
436
+ <Button title="Download" color="secondary" icon="download" onPress={() => { /* a real app zips or opens each selected file (openExternal) */ }} />
437
+ <Button title="Use AI" color="primary" onPress={openAi} />
438
+ </FloatingActionBar>
439
+
440
+ {/* Use AI — fork → running → review → done, all inside one dialog. The review
441
+ provider wraps the WHOLE dialog so the `Change`s (scroll area) and the
442
+ commit bar (`DialogFooter`) share one review context. */}
443
+ <ChangeReview>
444
+ <Dialog open={aiOpen} onOpenChange={(o) => { if (!o) closeAi(); }} maxWidth={620}>
445
+ <DialogHeader>
446
+ <DialogHeaderTitle>{task === "extract" ? "Extract data" : task === "check" ? "Cross-check" : `Use AI · ${picked.length} ${picked.length === 1 ? "file" : "files"}`}</DialogHeaderTitle>
447
+ </DialogHeader>
448
+ <DialogScrollArea>
449
+ {phase === "fork" ? (
450
+ <View style={{ gap: 16 }}>
451
+ <View style={{ gap: 8 }}>{picked.map((f, fi) => <FileRow key={f.id} name={f.name} mimeType={f.mimeType} onPress={() => openPreview(picked, fi)} />)}</View>
452
+ <View style={{ gap: 8 }}>
453
+ <CardSelectItem accessibilityLabel="Extract data — read the documents and fill the record" onPress={() => setTaskChoice("extract")} selected={taskChoice === "extract"} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
454
+ <Icon name="scan" size={18} color={colors.zinc[700]} />
455
+ <View style={{ flex: 1, gap: 2 }}>
456
+ <Text size="sm" weight="semibold">Extract data</Text>
457
+ <Text size="xs" color="muted">Read the documents and fill the record — what's new, what changes, what conflicts.</Text>
458
+ </View>
459
+ </CardSelectItem>
460
+ <CardSelectItem accessibilityLabel="Cross-check — compare the documents against the record" onPress={() => setTaskChoice("check")} selected={taskChoice === "check"} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
461
+ <Icon name="list-checks" size={18} color={colors.zinc[700]} />
462
+ <View style={{ flex: 1, gap: 2 }}>
463
+ <Text size="sm" weight="semibold">Cross-check</Text>
464
+ <Text size="xs" color="muted">Compare the documents against the record and each other — what disagrees and why.</Text>
465
+ </View>
466
+ </CardSelectItem>
467
+ <CardSelectItem accessibilityLabel="Edit with AI — open the chat agent with these documents" onPress={() => setTaskChoice("edit")} selected={taskChoice === "edit"} style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
468
+ <Icon name="square-pen" size={18} color={colors.zinc[700]} />
469
+ <View style={{ flex: 1, gap: 2 }}>
470
+ <Text size="sm" weight="semibold">Edit with AI</Text>
471
+ <Text size="xs" color="muted">Open the chat with the documents attached — describe the change, the agent edits them as new versions.</Text>
472
+ </View>
473
+ </CardSelectItem>
474
+ </View>
475
+ {/* Optional steering for the CHECK — findings serve ANY file-based
476
+ request the user briefs, not just the stock cross-check. */}
477
+ {taskChoice === "check" ? (
478
+ <FormTextInput
479
+ label="Instructions (optional)"
480
+ placeholder="Anything specific to check — e.g. verify the HS codes against the order"
481
+ value={brief}
482
+ onChangeText={setBrief}
483
+ multiline
484
+ accessibilityLabel="Instructions for the agent"
485
+ />
486
+ ) : null}
487
+ </View>
488
+ ) : null}
489
+
490
+ {phase === "running" ? <AgentRun items={runItems} state={revealed >= script.length ? "done" : "streaming"} /> : null}
491
+
492
+ {phase === "review" && task === "extract" ? (
493
+ <View style={{ gap: 16 }}>
494
+ <View style={{ gap: 8 }}>
495
+ <Text size="md" weight="semibold">Files read</Text>
496
+ <View style={{ gap: 8 }}>{picked.map((f, fi) => <FileRow key={f.id} name={f.name} mimeType={f.mimeType} onPress={() => openPreview(picked, fi)} />)}</View>
497
+ </View>
498
+ <View style={{ gap: 8 }}>
499
+ <ChangeReviewHeader />
500
+ {/* ONE section for the RECORD — ChangeFields stacks a ChangeField
501
+ per proposed value: label · the − band when replacing · the
502
+ editable + value. Editing IS the review; each field decides
503
+ for ITSELF (Keep/Drop), and Apply commits the kept rows. */}
504
+ <Change id="order">
505
+ <ChangeReasoning>{EXTRACT_REASONING}</ChangeReasoning>
506
+ <ChangeFields>
507
+ {/* An ADD — the record holds nothing yet, so no `before`. */}
508
+ <ChangeField label="MBL number" value={mbl} summary={mbl} {...fieldRow("mbl")}>
509
+ <ChangeValueInput value={mbl} onChangeText={setMbl} accessibilityLabel="MBL number" />
510
+ </ChangeField>
511
+ {/* An UPDATE — the current value banded above the editor. */}
512
+ <ChangeField label="Vessel" value={vessel} summary={vessel} before={VESSEL_CURRENT} {...fieldRow("vessel")}>
513
+ <ChangeValueInput value={vessel} onChangeText={setVessel} accessibilityLabel="Vessel" />
514
+ </ChangeField>
515
+ {/* A CONFLICT — the sources disagree: the read-only outcome
516
+ band stays on its placeholder until the user picks a
517
+ candidate below or types a third value — never a
518
+ pre-selection; Keep is gated until resolved. */}
519
+ <ChangeField
520
+ label="Consignee"
521
+ value={consignee ?? ""}
522
+ summary={consignee ?? undefined}
523
+ before={CONSIGNEE_CURRENT}
524
+ valueReadOnly
525
+ placeholder="Pick a candidate below"
526
+ reasoning="The invoice and the packing list disagree — pick a candidate or type your own."
527
+ candidates={CONSIGNEE_OPTIONS.map((c, i2) => ({ value: c.value, source: c.source, selected: consigneePick === i2 }))}
528
+ onPickCandidate={(c) => { const i2 = CONSIGNEE_OPTIONS.findIndex((x) => x.value === c.value); setConsigneePick(i2); setConsignee(c.value); }}
529
+ customValue={customConsignee}
530
+ customSelected={consigneePick === "custom"}
531
+ onCustomSelect={() => { setConsigneePick("custom"); setConsignee(customConsignee || null); }}
532
+ onCustomValue={(v) => { setCustomConsignee(v); setConsignee(v || null); }}
533
+ keepDisabled={consignee == null}
534
+ {...fieldRow("consignee")}
535
+ />
536
+ {/* An ADD-only field — new information the documents carry:
537
+ the + band alone, same grammar as every diff. */}
538
+ <ChangeField label="Gross weight" value={grossWeight} summary={grossWeight} {...fieldRow("gross")}>
539
+ <ChangeValueInput value={grossWeight} onChangeText={setGrossWeight} unit="kg" accessibilityLabel="Gross weight" />
540
+ </ChangeField>
541
+ {/* A REMOVAL — the − band alone (empty value, no editor): the
542
+ documents show this value no longer applies. */}
543
+ <ChangeField
544
+ label="Notify party"
545
+ before={NOTIFY_CURRENT}
546
+ reasoning="No notify party appears on any document — the consignee is notified directly."
547
+ {...fieldRow("notify")}
548
+ />
549
+ </ChangeFields>
550
+ </Change>
551
+ {/* RECORD ops — the packing list also proposes ORDER LINES:
552
+ a ChangeRecord card per item (add = one card decision;
553
+ edit = its changed fields decide themselves). A divider +
554
+ breathing room set the sub-section off from the fields. */}
555
+ <View style={{ paddingTop: 10 }}>
556
+ <Divider />
557
+ </View>
558
+ <ChangeReviewHeader title="Order lines" />
559
+ <ChangeRecord
560
+ id="line-add"
561
+ tone="add"
562
+ title="New line"
563
+ status={lineAdd}
564
+ onAccept={() => setLineAdd("accepted")}
565
+ onReject={() => setLineAdd("rejected")}
566
+ onUndo={() => setLineAdd("pending")}
567
+ summary={`${newItem} (${newQty} pcs)`}
568
+ >
569
+ <ChangeField label="Item" value={newItem} summary={newItem}>
570
+ <ChangeValueInput value={newItem} onChangeText={setNewItem} accessibilityLabel="Item" />
571
+ </ChangeField>
572
+ <ChangeField label="Quantity" value={newQty} summary={`${newQty} pcs`}>
573
+ <ChangeValueInput value={newQty} onChangeText={setNewQty} unit="pcs" accessibilityLabel="Quantity" />
574
+ </ChangeField>
575
+ </ChangeRecord>
576
+ <ChangeRecord id="line-edit" tone="edit" title="Flat-pack cartons — existing line" summary={`Flat-pack cartons — ${editedQty} pcs`}>
577
+ <ChangeField label="Quantity" before="1,200 pcs" value={editedQty} summary={`${editedQty} pcs`} {...lineEditRow("qty")}>
578
+ <ChangeValueInput value={editedQty} onChangeText={setEditedQty} unit="pcs" accessibilityLabel="Line quantity" />
579
+ </ChangeField>
580
+ </ChangeRecord>
581
+ </View>
582
+ </View>
583
+ ) : null}
584
+
585
+ {phase === "review" && task === "check" ? (
586
+ <View style={{ gap: 16 }}>
587
+ <View style={{ gap: 8 }}>
588
+ <Text size="md" weight="semibold">Documents checked</Text>
589
+ <View style={{ gap: 8 }}>{picked.map((f, fi) => <FileRow key={f.id} name={f.name} mimeType={f.mimeType} onPress={() => openPreview(picked, fi)} />)}</View>
590
+ </View>
591
+ <View style={{ gap: 14 }}>
592
+ <ChangeReviewHeader title="Findings" />
593
+ {/* Display-only: findings inform the verdict the footer records.
594
+ The kit `Finding` owns severity word · title · detail · the
595
+ PROMINENT metric · Sources; hairlines separate them. */}
596
+ {FINDINGS.map((c, i) => (
597
+ <Fragment key={c.id}>
598
+ {i > 0 ? <Divider /> : null}
599
+ <Finding
600
+ severity={c.severity}
601
+ title={c.title}
602
+ detail={c.detail}
603
+ sources={(c.sources ?? []).map((name): SourceRef => ({ id: name, label: name, kind: "document" }))}
604
+ onOpenSource={() => { /* preview stub */ }}
605
+ >
606
+ {c.comparison ? <FindingComparison values={c.comparison.values} delta={c.comparison.delta} /> : null}
607
+ </Finding>
608
+ </Fragment>
609
+ ))}
610
+ </View>
611
+ </View>
612
+ ) : null}
613
+
614
+ {phase === "done" ? (
615
+ <CompletionState title={`${applyCount} ${applyCount === 1 ? "change" : "changes"} applied to PO-2481`} summary="The files stay on the record — each updated field keeps its source document." />
616
+ ) : null}
617
+ </DialogScrollArea>
618
+ {phase === "fork" ? (
619
+ <DialogFooter>
620
+ {uploadFlow ? <Button title="Save files only" color="muted" onPress={() => { commitUpload(); closeAi(); }} /> : <Button title="Cancel" color="muted" onPress={closeAi} />}
621
+ <Button
622
+ title={taskChoice === "extract" ? "Extract data" : taskChoice === "check" ? "Run cross-check" : taskChoice === "edit" ? "Open chat" : "Run"}
623
+ color="primary"
624
+ disabled={taskChoice == null}
625
+ onPress={() => {
626
+ if (!taskChoice) return;
627
+ if (uploadFlow) commitUpload();
628
+ if (taskChoice === "edit") {
629
+ // The app→chat handoff — a real app calls the SDK and closes:
630
+ // void askAi({
631
+ // file_ids: picked.map((f) => f.id),
632
+ // record_ids: [orderRecordId],
633
+ // prompt: "Update these documents — ",
634
+ // });
635
+ // The messenger opens a fresh chat with the files attached (a
636
+ // single file also opens previewed beside it); the prompt is
637
+ // prefilled, editable, never auto-sent.
638
+ editWithAi(picked);
639
+ closeAi();
640
+ return;
641
+ }
642
+ startTask(taskChoice);
643
+ }}
644
+ />
645
+ </DialogFooter>
646
+ ) : phase === "review" && task === "extract" ? (
647
+ <DialogFooter>
648
+ {/* N = the kept fields; the minKept swap disables Apply exactly at
649
+ N = 0 (the registry can't see field-level decisions, so the host
650
+ gates — and hands Keep-all its own handler). */}
651
+ <ChangeReviewActions onAcceptAll={keepAll} onDiscard={closeAi} discardLabel="Cancel" onApply={() => setPhase("done")} applyLabel={`Update record (${applyCount})`} minKept={0} applyDisabled={applyCount === 0} />
652
+ </DialogFooter>
653
+ ) : phase === "review" && task === "check" ? (
654
+ <DialogFooter>
655
+ {/* The findings ARE the outcome — the human reads them and goes to
656
+ act (Extract, a manual fix). No phantom "record verdict" write:
657
+ a persisted check-status would go stale on the next edit. */}
658
+ <Button title="Done" color="primary" onPress={() => { sel.clear(); closeAi(); }} />
659
+ </DialogFooter>
660
+ ) : phase === "done" ? (
661
+ <DialogFooter>
662
+ <Button title="Done" color="primary" onPress={() => { sel.clear(); closeAi(); }} />
663
+ </DialogFooter>
664
+ ) : null}
665
+ </Dialog>
666
+ </ChangeReview>
667
+
668
+ {/* Create documents — pick from the readiness checklist, generate, land back on the record. */}
669
+ <Dialog open={genOpen} onOpenChange={(o) => { if (!o) closeGen(); }} maxWidth={560}>
670
+ <DialogHeader><DialogHeaderTitle>{fillTarget ? `${fillTarget.label} — missing fields` : "Create documents"}</DialogHeaderTitle></DialogHeader>
671
+ <DialogScrollArea>
672
+ {fillTarget ? (
673
+ <View style={{ gap: 14 }}>
674
+ <Text size="sm" color="muted">These values save onto the order and unlock the document.</Text>
675
+ {(fillTarget.needs ?? []).map((n) => (
676
+ <FormTextInput
677
+ key={n.key}
678
+ label={n.label}
679
+ value={fillDraft[n.key] ?? ""}
680
+ onChangeText={(t) => setFillDraft((d) => ({ ...d, [n.key]: t }))}
681
+ accessibilityLabel={n.label}
682
+ />
683
+ ))}
684
+ </View>
685
+ ) : genRunning ? (
686
+ <AgentRun
687
+ items={genSteps.slice(0, Math.min(genRevealed + 1, genSteps.length)).map((st, i) => ({
688
+ type: "step", id: st.id, label: st.label, status: i < genRevealed ? "done" : "running",
689
+ }))}
690
+ state={genRevealed >= genSteps.length ? "done" : "streaming"}
691
+ />
692
+ ) : !genDone ? (
693
+ <View>
694
+ {PRODUCIBLE.map((p, i) => (
695
+ <Fragment key={p.id}>
696
+ {i > 0 ? <Divider /> : null}
697
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 10, minHeight: 44 }}>
698
+ <CheckboxInput accessibilityLabel={`Create ${p.label}`} checked={gen.has(p.id)} onChange={(on) => gen.toggle(p.id, on)} />
699
+ <View style={{ flex: 1, gap: 1 }}>
700
+ <Text size="sm" weight="medium">{p.label}</Text>
701
+ {missingOf(p).length > 0 ? (
702
+ <Text size="xs" color="warning">{`Missing: ${missingOf(p).map((n) => n.label).join(", ")}`}</Text>
703
+ ) : (
704
+ <Text size="xs" color="muted">All fields available</Text>
705
+ )}
706
+ </View>
707
+ {missingOf(p).length > 0 ? <Button title="Add missing fields" color="muted" onPress={() => openFill(p)} /> : null}
708
+ </View>
709
+ </Fragment>
710
+ ))}
711
+ </View>
712
+ ) : (
713
+ /* Review-before-commit, not a celebration: the files ARE the
714
+ message (no count headline), the footer carries the mechanics
715
+ (no instructional filler), and the one fact the rows can't show
716
+ is provenance — the single line this screen keeps. */
717
+ <View style={{ gap: 12 }}>
718
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
719
+ <Icon name="circle-check" size={16} color={colors.emerald[600]} />
720
+ <Text size="sm" weight="medium">Created from the order's data</Text>
721
+ </View>
722
+ <View style={{ gap: 4 }}>
723
+ {genPending.map((g, gi) => (
724
+ <FileRow
725
+ key={g.id}
726
+ name={g.name}
727
+ meta={`${g.kind} · ${fmtSize(g.sizeKB)}`}
728
+ mimeType={g.mimeType}
729
+ onPress={() => openPreview(genPending, gi)}
730
+ trailing={
731
+ <Button
732
+ title="Download"
733
+ color="secondary"
734
+ icon="download"
735
+ accessibilityLabel={`Download ${g.name}`}
736
+ onPress={() => { /* a real app: openExternal(g.url) */ }}
737
+ />
738
+ }
739
+ />
740
+ ))}
741
+ </View>
742
+ </View>
743
+ )}
744
+ </DialogScrollArea>
745
+ <DialogFooter>
746
+ {fillTarget ? (
747
+ <>
748
+ <Button title="Back" color="muted" onPress={() => setFillTarget(null)} />
749
+ <Button
750
+ title="Save fields"
751
+ color="primary"
752
+ disabled={(fillTarget.needs ?? []).some((n) => !fillDraft[n.key]?.trim())}
753
+ onPress={saveFill}
754
+ />
755
+ </>
756
+ ) : genRunning ? null : !genDone ? (
757
+ <>
758
+ <Button title="Cancel" color="muted" onPress={closeGen} />
759
+ <Button title={`Generate ${gen.count} ${gen.count === 1 ? "document" : "documents"}`} color="primary" disabled={gen.count === 0} onPress={generate} />
760
+ </>
761
+ ) : (
762
+ <>
763
+ <Button title="Discard" color="muted" onPress={closeGen} />
764
+ <Button title={`Add to record (${genPending.length})`} color="primary" onPress={addGeneratedToRecord} />
765
+ </>
766
+ )}
767
+ </DialogFooter>
768
+ </Dialog>
769
+ {/* Rename — small focused dialog; Save disabled while empty. */}
770
+ <Dialog open={renameTarget != null} onOpenChange={(o) => { if (!o) setRenameTarget(null); }} maxWidth={420}>
771
+ <DialogHeader><DialogHeaderTitle>Rename file</DialogHeaderTitle></DialogHeader>
772
+ <DialogScrollArea>
773
+ <FormTextInput label="Filename" value={renameDraft} onChangeText={setRenameDraft} accessibilityLabel="Filename" />
774
+ </DialogScrollArea>
775
+ <DialogFooter>
776
+ <Button title="Cancel" color="muted" onPress={() => setRenameTarget(null)} />
777
+ <Button title="Save" color="primary" disabled={renameDraft.trim() === ""} onPress={saveRename} />
778
+ </DialogFooter>
779
+ </Dialog>
780
+ {/* Mounted only while open — its per-file hooks want a stable list. */}
781
+ {preview ? (
782
+ <FileGalleryModal
783
+ files={preview.files}
784
+ activeIndex={preview.index}
785
+ onIndexChange={(i) => setPreview((p2) => (i == null || !p2 ? null : { ...p2, index: i }))}
786
+ />
787
+ ) : null}
788
+ </>
789
+ );
790
+ }