@lotics/ui 14.1.0 → 14.3.0

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