@lotics/ui 15.1.1 → 15.2.1

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 CHANGED
@@ -16,7 +16,7 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
16
16
  |---|---|
17
17
  | [docs/catalog.md](./docs/catalog.md) | **The complete inventory** — Reach-by-role (each data role → the ONE canonical component) + every `@lotics/ui/<module>` entry point (incl. `@lotics/ui/vite`'s `loticsOptimizeDeps` for a custom-code app's `vite.config.ts`). Read before building any screen; reuse first. |
18
18
  | [docs/data_entry.md](./docs/data_entry.md) | Which editing pattern for which job — inline edit, fieldset forms, find-or-create (`Combobox`), line items, handoffs, phased records, billing, tags, dispositions, attachments (incl. the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates. |
19
- | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI proposes, the human decides — composer, live run feed (`AgentRun`), review-before-apply, findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md). |
19
+ | [docs/ai_patterns.md](./docs/ai_patterns.md) | AI acts, the human stays in charge — composer, live run feed (`AgentRun`), the one law's split (modify → review-before-apply; create → save-direct + result list), findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md). |
20
20
  | [docs/composition.md](./docs/composition.md) | The design-language contract — canvas + content column, heading altitude, banded cards, register vs inset rows, master-detail `Drawer`, view controls, color discipline, typography, whitespace. |
21
21
  | [docs/templates.md](./docs/templates.md) | The map of `examples/tpl_*.tsx` — what shape each template solves and which to start from (copy + adapt, never import) — plus the record-surface composition rules (pipeline order, static shape, decision budget). |
22
22
 
@@ -11,19 +11,40 @@ file). Every component here is in the [catalog](./catalog.md) inventory.
11
11
 
12
12
  ## The one law
13
13
 
14
- The AI surfaces share ONE law: the agent never commits it **proposes**, the human
15
- accepts / edits / dismisses, the deterministic app applies. The agent owns judgment
16
- (recognition, estimation, intent→parameters); the app owns geometry, math, and the write.
17
- Compose the surfaces as a loop, and reach for the right one by job.
14
+ The AI surfaces share ONE law: the agent never commits **invisibly**. The agent owns judgment
15
+ (recognition, estimation, intent→parameters); the app owns geometry, math, and the write. How
16
+ the human stays in charge follows the stakes split by whether a before-state exists:
17
+
18
+ - **Modifying an existing record** — a diff exists, and a wrong value can clobber a right one →
19
+ review BEFORE apply: the `ChangeReview` family, Keep/Drop per change, nothing auto-applies.
20
+ - **Creating new records** (intake, import, drafting rows) — there is no before-state, so there
21
+ is nothing to diff → **apply directly, then show the result**. The run ends on a per-item
22
+ result list — the receipt, not a gate. A result row is a plain composition (no component —
23
+ hand-roll it): the outcome mark (`success` = `circle-check` green-600 · `error` =
24
+ `circle-alert` red-600 · needs-attention = `circle-alert` amber-600 · skipped = `minus`
25
+ zinc-400, size 18) · outcome-first title (sm semibold) · the key figures / the reason (xs,
26
+ danger ink on error) · one-line provenance (xs muted) · a right-slot `Button` (an
27
+ open-record jump, a retry); rows stack with `gap: 12`. A batch-level failure lands on
28
+ EVERY row as its reason — never ALSO paint it as a banner above the list (a top `Callout`
29
+ is only for information the rows don't carry). The closing footer button is
30
+ dismissal (secondary "Done"), never primary. Correction uses
31
+ the record's ORDINARY verbs — open it, edit or delete (make the delete workflow CASCADE the
32
+ record's children, so deletion is always safe) — never a special undo affordance duplicating
33
+ the delete verb in a second place. A review gate on a create degenerates into proofreading a
34
+ wall of cards the user blind-confirms; the register shows the same data better. Fewer steps,
35
+ same control.
36
+
37
+ Either way the deterministic app applies the write. Compose the surfaces as a loop, and reach
38
+ for the right one by job.
18
39
 
19
40
  ## Which AI surface — decide by the OUTCOME's shape, not the task's topic
20
41
 
21
42
  Three shapes:
22
43
 
23
- 1. **Field writes** (extract, match, rank, classify → the record changes) → **in-app agent**
24
- (`useAgentRun`) + **`ChangeReview`**. The commits must obey the diff law (Keep/Drop per
25
- change, app-workflow writes, bounded app authority), and the operator wants one button +
26
- a review never a prompt box.
44
+ 1. **Field writes** (extract, match, rank, classify → records change) → **in-app agent**
45
+ (`useAgentRun`), then split by the one law: modifying an existing record → **`ChangeReview`**
46
+ (Keep/Drop per change, app-workflow writes, bounded app authority); creating new records
47
+ **save direct + a result list** (correction = the record's ordinary verbs). Either way one button, never a prompt box.
27
48
  2. **Evidence for an in-app decision** (cross-check, audit, tie-out → nothing is written; the
28
49
  operator acts on what was found) → **in-app agent** + **`Finding`**. Still bounded and
29
50
  prompt-free (an optional instructions brief at most): the brief is fixed, the output is
@@ -161,6 +182,13 @@ for free — no hand-assembly): `<AgentRun parts={run.parts} state={…} error={
161
182
  [the SDK doc](../../app-sdk/docs/ai.md) for the hook. The `ai` package is a regular dependency of
162
183
  `@lotics/ui` — its part TYPES resolve transitively in any consumer's typecheck with nothing to
163
184
  install; the imports are type-only (purity-enforced), so no `ai` runtime ever enters a bundle.
185
+ **A run with no parts yet renders the breathing "Starting…" row** (streaming state only) — the
186
+ live dot plus a label that pulses, covering the gap between the CTA press and the first streamed
187
+ part (upload + run creation + first token); a silent feed reads as broken. **The law: pass
188
+ `state="streaming"` from the moment the paid CTA fires — uploads included — and render
189
+ `AgentRun` even with zero parts. Never hand-roll a text/skeleton placeholder in front of the
190
+ feed**; the starting row IS the placeholder, consistent and localized.
191
+
164
192
  **Reasoning only shows when the model produces it** — the declared model must support thinking
165
193
  (Sonnet / Opus, not Haiku); the renderer shows every reasoning part it's handed.
166
194
 
@@ -197,6 +225,13 @@ into it while running and reveals again when done.
197
225
 
198
226
  ## Review before apply — ONE surface, the compound `ChangeReview` family
199
227
 
228
+ **When to reach for it — the one law's boundary.** `ChangeReview` is for MODIFYING an existing
229
+ record: a diff exists (the `−` band has something real to show) and a wrong extraction can
230
+ overwrite a right value — the record page's document desk is the home ground. Do NOT put it in
231
+ front of a creation flow (intake, import, bulk drafting): there is no before-state to diff, so
232
+ the "review" is a proofread the user blind-confirms — save direct and end on a result list
233
+ instead (see [the one law](#the-one-law) and `tpl_item_list`'s intake).
234
+
200
235
  Every "the agent proposes → the human accepts / edits / dismisses → nothing auto-applies" surface
201
236
  composes from twelve pieces in three groups (all in `@lotics/ui/change_review`; worked example:
202
237
  [`tpl_record`](../examples/tpl_record.tsx)'s Use-AI extract flow).
@@ -306,9 +341,13 @@ Back / Next / Cancel / Submit (Next/Submit primary), the step position an eyebro
306
341
  question — no progress bar, and no eyebrow at all for a single question. Each step is a `Clarify`; advance only once the
307
342
  current question is answered; `onSubmit` gives one `{ value, custom }` per question (aligned by index,
308
343
  `custom` true when the answer came from "Other…"), `onCancel` dismisses. All navigation chrome is
309
- locale-wired (the `clarify` slice). The multi-question form of the ask-back. Worked example:
344
+ locale-wired (the `clarify` slice). The multi-question form of the ask-back. **Placement follows
345
+ the dialog grammar**: standalone (riding a parked run's stream, where no footer exists) the wizard
346
+ renders its own inline action row; as a DIALOG phase, wrap the `Dialog` with `ClarifyWizardScope`
347
+ and put `ClarifyWizardActions` in the `DialogFooter` — the wizard suppresses the inline row and
348
+ drives the footer bar (same verbs, same answered-gating). Worked example:
310
349
  [`tpl_item_list`](../examples/tpl_item_list.tsx)'s intake fork (the wizard between the analyze
311
- and import runs).
350
+ and import runs, its actions in the footer).
312
351
 
313
352
  ## Provenance — `Sources`
314
353
 
@@ -408,9 +447,13 @@ ranked set, don't cram it into chat prose:
408
447
  the two AI tasks into one mixed output — the fork is the design.
409
448
  - **Intake desk** (in [`tpl_item_list`](../examples/tpl_item_list.tsx)'s "Enter data" fork) — the
410
449
  register's New CTA leads with AI: a `FileDropzone` hero → a short ANALYZE run reads the files →
411
- `ClarifyWizard` asks the ambiguities the analysis surfaced the IMPORT run drafts records →
412
- `ChangeRecord` cards + one Apply land the rows; the manual VARIANTS sit below the dropzone as
413
- `CardSelectItem`s, each opening the classic create-then-refine form. The template mocks the
450
+ `ClarifyWizard` asks the ambiguities the analysis surfaced (its Cancel/Back/Next/Submit riding
451
+ the `DialogFooter` via `ClarifyWizardScope` + `ClarifyWizardActions`) the IMPORT run
452
+ **creates the records and the dialog ends on the RESULT LIST** — one result row per record
453
+ (what landed, the key figures — the hand-rolled anatomy in the one law above) — per the one
454
+ law's creation branch: no review gate, the register IS the review, correction is the
455
+ record's ordinary verbs. The manual VARIANTS sit below the dropzone
456
+ as `CardSelectItem`s, each opening the classic create-then-refine form. The template mocks the
414
457
  clarify step as TWO app-orchestrated phases (analyze-run → wizard → import-run); a real app
415
458
  can do it in ONE run — every app agent carries `ask_user_choice`, so the run PARKS on the
416
459
  agent's own question and `useAgentRun().pendingChoice`/`answerChoice` drive the same
package/docs/catalog.md CHANGED
@@ -1087,7 +1087,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1087
1087
  of `Clarify` questions with Back/Next/Cancel/Submit + a `n / total` position indicator (omitted
1088
1088
  for a single question); advance
1089
1089
  only when the current is answered, `onSubmit` returns one `{ value, custom }` per question.
1090
- Navigation chrome is locale-wired (the `clarify` locale slice).
1090
+ Navigation chrome is locale-wired (the `clarify` locale slice). Standalone it renders its own
1091
+ inline action row (a parked run's stream has no footer); inside a dialog, wrap the `Dialog`
1092
+ with **`ClarifyWizardScope`** and put **`ClarifyWizardActions`** in the `DialogFooter` — the
1093
+ wizard suppresses the inline row and drives the footer bar (same verbs, same gating), per the
1094
+ dialog grammar's footer-owns-actions law (worked example: `tpl_item_list`'s clarify phase).
1091
1095
  - **`choice_list`** — `ChoiceList` + `ChoiceOption`: selectable answer options as
1092
1096
  divider-separated rows (no bordered cards) with a per-row focus ring + hover wash; the
1093
1097
  agent's quick-reply surface. `allowCustom` appends an always-visible borderless multiline field whose
package/docs/templates.md CHANGED
@@ -218,9 +218,14 @@ screens — register, per-row action, gated selection, and act-on-many in one. T
218
218
  button). The dialog is CONTROLLED (`open`/`onOpenChange` + `seedNames`) so both the button and
219
219
  the top-level drop drive it. The dialog itself: a `FileDropzone` hero (drop the customer's files) → a short ANALYZE stream
220
220
  (`AgentRun` in a `FollowScroll`) reads them → `ClarifyWizard` asks the ambiguities the
221
- analysis surfaced (described options, one custom-answer slot) the IMPORT stream drafts the
222
- records `ChangeRecord` cards to Keep/Drop (fee editable in place) + one outcome-named
223
- Apply that really lands the rows in the register (no drawer on a bulk landing). BELOW the
221
+ analysis surfaced (described options, one custom-answer slot; its Cancel/Back/Next/Submit
222
+ live in the `DialogFooter` `ClarifyWizardScope` wraps the Dialog, `ClarifyWizardActions`
223
+ rides the footer) the IMPORT stream CREATES the records and the dialog ends on the RESULT
224
+ LIST — one hand-rolled result row per record (outcome mark · what landed · key figures —
225
+ the anatomy in ai_patterns §the one law; secondary "Done" to dismiss) — the one law's
226
+ creation branch
227
+ (ai_patterns.md): no review gate on a create, the register IS the review, correction uses
228
+ the record's ordinary verbs (no drawer on a bulk landing either). BELOW the
224
229
  dropzone, "or enter manually" → the record VARIANTS as `CardSelectItem`s, each opening the
225
230
  same THREE-field create-then-refine form (a single manual create still opens its drawer).
226
231
  The template mocks the clarify step as two app-orchestrated phases; a real app can do it in
@@ -54,11 +54,10 @@ import { ProgressBar } from "@lotics/ui/progress_bar";
54
54
  import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle, DialogScrollArea } from "@lotics/ui/dialog";
55
55
  import { AgentRun } from "@lotics/ui/agent_run";
56
56
  import { FollowScroll } from "@lotics/ui/follow_scroll";
57
- import { ClarifyWizard, type ClarifyWizardAnswer, type ClarifyWizardQuestion } from "@lotics/ui/clarify_wizard";
57
+ import { ClarifyWizard, ClarifyWizardActions, ClarifyWizardScope, 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
60
  import { FileDropTarget } from "@lotics/ui/file_drop_target";
61
- import { ChangeField, ChangeRecord, ChangeReview, ChangeReviewActions, ChangeReviewHeader, ChangeValueInput, type ChangeStatus } from "@lotics/ui/change_review";
62
61
  import { useScreenSize } from "@lotics/ui/use_screen_size";
63
62
  import { MemberSelect } from "@lotics/ui/member_select";
64
63
  import { Callout, CalloutText } from "@lotics/ui/callout";
@@ -1049,8 +1048,13 @@ function LinkedRecordScreen({ ma }: { ma: string }) {
1049
1048
  // ─── Enter data — the INTAKE fork ────────────────────────────────────────────
1050
1049
  // AI FIRST, form as fallback. The one "Enter data" CTA opens a phased dialog:
1051
1050
  // drop files (the hero) → a short ANALYZE stream reads them → `ClarifyWizard`
1052
- // asks the genuine ambiguities the analysis surfaced → the IMPORT stream drafts
1053
- // the records `ChangeRecord` cards to review + apply (rows really land).
1051
+ // asks the genuine ambiguities the analysis surfaced → the IMPORT stream
1052
+ // CREATES the records and the dialog ends on the RESULT LIST one row per
1053
+ // record with its key figures. This is the one law's creation branch: no
1054
+ // review gate on a create (nothing to diff — the register IS the review);
1055
+ // correction uses the record's ORDINARY verbs (open it, edit or delete);
1056
+ // `ChangeReview` belongs to the record page, where a SINGLE existing record
1057
+ // is being modified.
1054
1058
  // Below the dropzone, the MANUAL variants (each flavors the same 3-field
1055
1059
  // create-then-refine gate). The mock plays the clarify step as two phases; a
1056
1060
  // real app can run it as ONE agent run — every app agent carries
@@ -1058,7 +1062,7 @@ function LinkedRecordScreen({ ma }: { ma: string }) {
1058
1062
  // `useAgentRun().pendingChoice`/`answerChoice` drive this same wizard.
1059
1063
 
1060
1064
  type Part = UIMessagePart<UIDataTypes, UITools>;
1061
- type IntakePhase = "intake" | "analyze" | "clarify" | "running" | "review" | "form";
1065
+ type IntakePhase = "intake" | "analyze" | "clarify" | "running" | "done" | "form";
1062
1066
  type ManualVariant = "export" | "import";
1063
1067
 
1064
1068
  type Proposal = { id: string; khach: string; dienThoai: string; phi: number; orders: string };
@@ -1107,8 +1111,8 @@ const importScript = (grouping: "customer" | "order"): Part[] => [
1107
1111
  { type: "reasoning", text: grouping === "customer" ? "Group the six orders under their three customers, one record each, fees summed per customer." : "One record per order — six records, fees split from the order totals." },
1108
1112
  doneTool("i1", "Extract order lines"),
1109
1113
  doneTool("i2", "Match customers"),
1110
- doneTool("i3", grouping === "customer" ? "Draft 3 records" : "Draft 6 records"),
1111
- { type: "text", text: `Drafted **${grouping === "customer" ? 3 : 6} records** — review below, edit any value, then apply.` },
1114
+ doneTool("i3", grouping === "customer" ? "Create 3 records" : "Create 6 records"),
1115
+ { type: "text", text: `Created **${grouping === "customer" ? 3 : 6} records** — they are in the register.` },
1112
1116
  ];
1113
1117
 
1114
1118
  /** The manual gate stays the THREE-field create-then-refine form — past the
@@ -1125,6 +1129,7 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
1125
1129
  * auto-analyze), ready for the deliberate "Read with AI". Empty from the button. */
1126
1130
  seedDocs: DisplayFile[];
1127
1131
  onCreate: (khach: string, dienThoai: string, phi: number) => void;
1132
+ /** The import's save — rows land in the register as the stream finishes. */
1128
1133
  onCreateMany: (records: { khach: string; dienThoai: string; phi: number }[]) => void;
1129
1134
  }) {
1130
1135
  const { small } = useScreenSize();
@@ -1133,12 +1138,11 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
1133
1138
  const [variant, setVariant] = useState<ManualVariant>("export");
1134
1139
  const [grouping, setGrouping] = useState<"customer" | "order">("customer");
1135
1140
  // Q2's answer is load-bearing too: it decides how the unknown customer lands
1136
- // on the drafted records (create + link, leave unlinked, or the user's own
1141
+ // on the created records (create + link, leave unlinked, or the user's own
1137
1142
  // instruction via the custom slot).
1138
1143
  const [customerPlan, setCustomerPlan] = useState("New customer created and linked");
1139
- // review stateper-card verdicts + the one editable value (the fee)
1140
- const [cardStatus, setCardStatus] = useState<Record<string, ChangeStatus>>({});
1141
- const [fees, setFees] = useState<Record<string, string>>({});
1144
+ // the result list what the import CREATED, one entry per record
1145
+ const [landed, setLanded] = useState<{ title: string; detail: string }[]>([]);
1142
1146
  // form state (the manual gate)
1143
1147
  const [khach, setKhach] = useState("");
1144
1148
  const [dienThoai, setDienThoai] = useState("");
@@ -1169,8 +1173,25 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
1169
1173
  setRevealed(n);
1170
1174
  if (n >= total) {
1171
1175
  clearInterval(t);
1172
- // Auto-advance: analysis flows into the wizard; the import into review.
1173
- setTimeout(() => setPhase(phase === "analyze" ? "clarify" : "review"), 500);
1176
+ if (phase === "analyze") {
1177
+ // Auto-advance: analysis flows into the wizard.
1178
+ setTimeout(() => setPhase("clarify"), 500);
1179
+ } else {
1180
+ // The import SAVES as it lands — the records are created here, and the
1181
+ // dialog advances to the result list where each row carries Undo.
1182
+ setTimeout(() => {
1183
+ const proposals = grouping === "customer" ? PROPOSED_BY_CUSTOMER : PROPOSED_BY_ORDER;
1184
+ onCreateMany(proposals.map((p) => ({ khach: p.khach, dienThoai: p.dienThoai, phi: p.phi })));
1185
+ setLanded(proposals.map((p) => ({
1186
+ title: grouping === "customer" ? p.khach : `${p.orders} — ${p.khach}`,
1187
+ // The wizard's answers are load-bearing: the unknown customer's row
1188
+ // states how it landed (created + linked / left unassigned / the
1189
+ // user's own instruction).
1190
+ detail: [p.orders, p.dienThoai, formatMoney(p.phi), p.khach === "Blue Harbor Foods" ? customerPlan : ""].filter(Boolean).join(" · "),
1191
+ })));
1192
+ setPhase("done");
1193
+ }, 500);
1194
+ }
1174
1195
  }
1175
1196
  }, 650);
1176
1197
  return () => clearInterval(t);
@@ -1183,16 +1204,10 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
1183
1204
  // dropzone, and the compact add-more dropzone all call this.
1184
1205
  const addDocs = (fs: File[]) => setDocs((prev) => [...prev, ...toDisplayFiles(fs)]);
1185
1206
 
1186
- const proposals = grouping === "customer" ? PROPOSED_BY_CUSTOMER : PROPOSED_BY_ORDER;
1187
- // Kept = explicitly ACCEPTED (the kit's review contract): Apply stays disabled
1188
- // at 0 kept, and the bar's own Keep-all presses every pending card's Keep.
1189
- const keptCount = proposals.filter((p) => cardStatus[p.id] === "accepted").length;
1190
-
1191
1207
  const reset = () => {
1192
1208
  setPhase("intake");
1193
1209
  setDocs([]);
1194
- setCardStatus({});
1195
- setFees({});
1210
+ setLanded([]);
1196
1211
  setCustomerPlan("New customer created and linked");
1197
1212
  setKhach("");
1198
1213
  setDienThoai("");
@@ -1200,12 +1215,6 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
1200
1215
  };
1201
1216
  const close = () => { onOpenChange(false); reset(); };
1202
1217
 
1203
- const applyProposals = () => {
1204
- const kept = proposals.filter((p) => cardStatus[p.id] === "accepted");
1205
- onCreateMany(kept.map((p) => ({ khach: p.khach, dienThoai: p.dienThoai, phi: Number(fees[p.id] ?? p.phi) || 0 })));
1206
- close();
1207
- };
1208
-
1209
1218
  const createManual = () => {
1210
1219
  if (khach.trim() === "") return;
1211
1220
  onCreate(khach.trim(), dienThoai.trim(), phi ?? 0);
@@ -1215,14 +1224,18 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
1215
1224
  const title =
1216
1225
  phase === "form" ? (variant === "export" ? "New export case" : "New import case")
1217
1226
  : phase === "intake" ? "Enter data"
1227
+ : phase === "done" ? "Saved"
1218
1228
  : "Import from files";
1219
1229
 
1220
1230
  return (
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. */}
1231
+ <ClarifyWizardScope>
1232
+ {/* the file/stream phases (intake/analyze/clarify/running/done) run
1233
+ WIDE — room for the thumbnail hero + the AgentRun/ClarifyWizard/result
1234
+ panes; only the manual `form` stays a narrow single-column pane.
1235
+ 760 sits just under the kit Dialog's default 786 maxWidth cap.
1236
+ ClarifyWizardScope wraps the Dialog from OUTSIDE so the wizard's
1237
+ Cancel/Back/Next/Submit render in the DialogFooter — the dialog
1238
+ grammar's home for action bars — via ClarifyWizardActions. */}
1226
1239
  <Dialog width={phase === "form" ? 480 : 760} open={open} onOpenChange={(o) => { if (!o) close(); }}>
1227
1240
  <DialogHeader>
1228
1241
  <DialogHeaderTitle>{title}</DialogHeaderTitle>
@@ -1324,31 +1337,27 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
1324
1337
  </View>
1325
1338
  ) : null}
1326
1339
 
1327
- {phase === "review" ? (
1340
+ {phase === "done" ? (
1341
+ // The RESULT LIST — one row per created record: mark · what landed ·
1342
+ // the key figures. The receipt, not a gate: correcting a record uses
1343
+ // its ORDINARY verbs (open it in the register, edit or delete — the
1344
+ // app's cascade delete keeps that safe), never a special undo here.
1345
+ // The outcome-mark vocabulary (ai_patterns §the one law): success =
1346
+ // circle-check green-600 · error = circle-alert red-600 · attention =
1347
+ // circle-alert amber-600 · skipped = minus zinc-400. A real app adds
1348
+ // an open-record jump / retry Button on the row's right.
1328
1349
  <DialogScrollArea>
1329
- <View style={{ gap: 8 }}>
1330
- <ChangeReviewHeader title={`${proposals.length} records drafted`} />
1331
- {proposals.map((p) => (
1332
- <ChangeRecord
1333
- key={p.id}
1334
- id={p.id}
1335
- tone="add"
1336
- title={grouping === "customer" ? p.khach : `${p.orders} ${p.khach}`}
1337
- summary={`${p.orders}${p.dienThoai ? ` · ${p.dienThoai}` : ""}`}
1338
- status={cardStatus[p.id] ?? "pending"}
1339
- onAccept={() => setCardStatus((s) => ({ ...s, [p.id]: "accepted" }))}
1340
- onReject={() => setCardStatus((s) => ({ ...s, [p.id]: "rejected" }))}
1341
- onUndo={() => setCardStatus((s) => ({ ...s, [p.id]: "pending" }))}
1342
- >
1343
- <ChangeField label="Customer" value={p.khach} summary={p.khach} />
1344
- {p.khach === "Blue Harbor Foods" ? (
1345
- <ChangeField label="Customer link" value={customerPlan} summary={customerPlan} />
1346
- ) : null}
1347
- {p.dienThoai ? <ChangeField label="Phone" value={p.dienThoai} summary={p.dienThoai} /> : null}
1348
- <ChangeField label="Service fee" value={fees[p.id] ?? String(p.phi)} summary={formatMoney(Number(fees[p.id] ?? p.phi) || 0)}>
1349
- <ChangeValueInput value={fees[p.id] ?? String(p.phi)} onChangeText={(v) => setFees((s) => ({ ...s, [p.id]: v }))} accessibilityLabel={`Service fee for ${p.khach}`} />
1350
- </ChangeField>
1351
- </ChangeRecord>
1350
+ <View style={{ gap: 12 }}>
1351
+ {landed.map((l, i) => (
1352
+ <View key={i} style={{ flexDirection: "row", alignItems: "flex-start", gap: 12 }}>
1353
+ <View style={{ paddingTop: 2 }}>
1354
+ <Icon name="circle-check" size={18} color={colors.green[600]} />
1355
+ </View>
1356
+ <View style={{ flex: 1, gap: 2 }}>
1357
+ <Text size="sm" weight="semibold">{`Created ${l.title}`}</Text>
1358
+ <Text size="xs" color="muted" numberOfLines={1}>{l.detail}</Text>
1359
+ </View>
1360
+ </View>
1352
1361
  ))}
1353
1362
  </View>
1354
1363
  </DialogScrollArea>
@@ -1402,13 +1411,18 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
1402
1411
  <Button title="Back" color="secondary" onPress={() => { setKhach(""); setDienThoai(""); setPhi(null); setPhase("intake"); }} />
1403
1412
  <Button title="Create record" color="primary" disabled={khach.trim() === ""} onPress={createManual} />
1404
1413
  </DialogFooter>
1405
- ) : phase === "review" ? (
1414
+ ) : phase === "clarify" ? (
1415
+ <DialogFooter>
1416
+ <ClarifyWizardActions />
1417
+ </DialogFooter>
1418
+ ) : phase === "done" ? (
1406
1419
  <DialogFooter>
1407
- <ChangeReviewActions onDiscard={close} onApply={applyProposals} applyLabel={keptCount > 0 ? `Add ${keptCount} ${keptCount === 1 ? "record" : "records"}` : "Add records"} />
1420
+ {/* Dismissal, not an action the records already landed. */}
1421
+ <Button title="Done" color="secondary" onPress={close} />
1408
1422
  </DialogFooter>
1409
1423
  ) : null}
1410
1424
  </Dialog>
1411
- </ChangeReview>
1425
+ </ClarifyWizardScope>
1412
1426
  );
1413
1427
  }
1414
1428
 
@@ -1470,7 +1484,7 @@ export function TplItemList() {
1470
1484
  // A single manual create opens its drawer for refinement (create-then-refine).
1471
1485
  setOpenMa(ma);
1472
1486
  };
1473
- // The AI-import apply — rows land at the top of the register; no drawer (a
1487
+ // The AI-import save — rows land at the top of the register; no drawer (a
1474
1488
  // bulk landing is reviewed IN the register, refined record by record later).
1475
1489
  const createRecordsBulk = (records: { khach: string; dienThoai: string; phi: number }[]) => {
1476
1490
  records.forEach((r) => addRow(r.khach, r.dienThoai, r.phi));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "15.1.1",
3
+ "version": "15.2.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
package/src/agent_run.tsx CHANGED
@@ -1,5 +1,5 @@
1
- import { useState, type ReactNode } from "react";
2
- import { StyleSheet, View } from "react-native";
1
+ import { useEffect, useRef, useState, type ReactNode } from "react";
2
+ import { Animated, StyleSheet, View } from "react-native";
3
3
  import { colors } from "./colors";
4
4
  import { Text } from "./text";
5
5
  import { Button } from "./button";
@@ -136,6 +136,13 @@ export function AgentRun(props: AgentRunProps) {
136
136
 
137
137
  return (
138
138
  <View accessibilityLabel={accessibilityLabel} style={{ gap: 10 }}>
139
+ {/* The run has started but nothing has streamed yet (upload, run creation,
140
+ the model's first token) — a silent feed reads as broken, so the pulsing
141
+ starting row gives instant feedback until the first real part. This IS
142
+ the canonical empty-live state: hosts pass state="streaming" from the
143
+ CTA press (uploads included) and never hand-roll a text/skeleton
144
+ placeholder in front of the feed. */}
145
+ {streaming && segments.length === 0 ? <StartingRow label={locale.agentRun.starting} /> : null}
139
146
  {segments.map((seg, i) => {
140
147
  if (seg.kind === "text") {
141
148
  return (
@@ -284,6 +291,33 @@ function StepRow({ s, label, renderToolOutput }: { s: AgentStep; label: string;
284
291
 
285
292
  // Thinking — a muted, COLLAPSED "Thinking" disclosure (revealed on press), so the
286
293
  // agent's reasoning never crowds the answer. Pulses while it's still streaming.
294
+ /** The empty-live feed row: the live dot's halo plus a BREATHING label — the
295
+ * message itself pulses (Skeleton's opacity rhythm), so "work has started"
296
+ * reads at a glance even before the first token. */
297
+ function StartingRow({ label }: { label: string }) {
298
+ const v = useRef(new Animated.Value(0.45)).current;
299
+ useEffect(() => {
300
+ const loop = Animated.loop(
301
+ Animated.sequence([
302
+ Animated.timing(v, { toValue: 1, duration: 700, useNativeDriver: false }),
303
+ Animated.timing(v, { toValue: 0.45, duration: 700, useNativeDriver: false }),
304
+ ]),
305
+ );
306
+ loop.start();
307
+ return () => loop.stop();
308
+ }, [v]);
309
+ return (
310
+ <View style={styles.row}>
311
+ <View style={styles.dotCol}>
312
+ <Marker status="current" color={colors.zinc[400]} live />
313
+ </View>
314
+ <Animated.View style={[styles.rowBody, { opacity: v }]}>
315
+ <Text size="sm" color="muted">{label}</Text>
316
+ </Animated.View>
317
+ </View>
318
+ );
319
+ }
320
+
287
321
  function ReasoningDisclosure(props: { text: string; streaming?: boolean; expanded: boolean; onToggle: () => void }) {
288
322
  const { text, streaming, expanded, onToggle } = props;
289
323
  const locale = useLoticsLocale();
@@ -1,4 +1,4 @@
1
- import { useState } from "react";
1
+ import { createContext, useContext, useEffect, useRef, useState, type ReactNode } from "react";
2
2
  import { View } from "react-native";
3
3
  import { Button } from "./button";
4
4
  import { Clarify, type ClarifyOption } from "./clarify";
@@ -26,16 +26,52 @@ export interface ClarifyWizardProps {
26
26
  onCancel: () => void;
27
27
  }
28
28
 
29
+ /** What the wizard's action bar needs, published by the wizard body into the
30
+ * scope so `ClarifyWizardActions` can render Cancel/Back/Next/Submit from a
31
+ * `DialogFooter` — outside the wizard's own subtree. */
32
+ interface ClarifyWizardHandle {
33
+ index: number;
34
+ total: number;
35
+ answered: boolean;
36
+ back: () => void;
37
+ next: () => void;
38
+ submit: () => void;
39
+ cancel: () => void;
40
+ }
41
+
42
+ interface ClarifyWizardScopeValue {
43
+ handle: ClarifyWizardHandle | null;
44
+ setHandle: (h: ClarifyWizardHandle | null) => void;
45
+ }
46
+
47
+ const ScopeContext = createContext<ClarifyWizardScopeValue | null>(null);
48
+
49
+ /**
50
+ * The compound frame for a wizard whose actions live in the dialog's footer —
51
+ * the dialog grammar's home for action bars. Wrap the `Dialog` from OUTSIDE
52
+ * (the `ChangeReview` position), put the `ClarifyWizard` in the content pane
53
+ * and `ClarifyWizardActions` in the `DialogFooter`; the wizard then suppresses
54
+ * its inline action row and drives the bar through this scope. One wizard per
55
+ * scope. Standalone `ClarifyWizard` (no scope — e.g. riding a parked run's
56
+ * stream, where no footer exists) keeps its inline actions.
57
+ */
58
+ export function ClarifyWizardScope({ children }: { children: ReactNode }) {
59
+ const [handle, setHandle] = useState<ClarifyWizardHandle | null>(null);
60
+ return <ScopeContext.Provider value={{ handle, setHandle }}>{children}</ScopeContext.Provider>;
61
+ }
62
+
29
63
  /**
30
64
  * A SEQUENCE of `Clarify` questions the human works through one at a time —
31
65
  * Back / Next / Cancel / Submit with a position indicator. Each step reuses
32
66
  * `Clarify` (question + described answers + optional "Other…"). You can only
33
67
  * advance once the current question is answered; Submit fires when the last is.
34
68
  * The multi-question form of the agent's ask-back; single questions use `Clarify`.
69
+ * Inside a `ClarifyWizardScope`, the action row moves to `ClarifyWizardActions`.
35
70
  */
36
71
  export function ClarifyWizard(props: ClarifyWizardProps) {
37
72
  const { questions, onSubmit, onCancel } = props;
38
73
  const labels = useLoticsLocale().clarify;
74
+ const scope = useContext(ScopeContext);
39
75
  const [index, setIndex] = useState(0);
40
76
  const [answers, setAnswers] = useState<(string | undefined)[]>(() => questions.map(() => undefined));
41
77
 
@@ -62,6 +98,28 @@ export function ClarifyWizard(props: ClarifyWizardProps) {
62
98
  }),
63
99
  );
64
100
 
101
+ // Publish the action-bar handle into the scope (and clear it on unmount) so
102
+ // the footer bar always reflects the CURRENT step's position and validity.
103
+ // The handle's callbacks read the latest submit/cancel through a ref — the
104
+ // host may recreate them every render, and a handle republished only on
105
+ // wizard-state changes would otherwise call a stale closure.
106
+ const latest = useRef({ submit, onCancel });
107
+ latest.current = { submit, onCancel };
108
+ const setHandle = scope?.setHandle;
109
+ useEffect(() => {
110
+ if (!setHandle) return;
111
+ setHandle({
112
+ index,
113
+ total,
114
+ answered,
115
+ back: () => setIndex((i) => Math.max(0, i - 1)),
116
+ next: () => setIndex((i) => Math.min(total - 1, i + 1)),
117
+ submit: () => latest.current.submit(),
118
+ cancel: () => latest.current.onCancel(),
119
+ });
120
+ return () => setHandle(null);
121
+ }, [setHandle, index, total, answered]);
122
+
65
123
  return (
66
124
  <View style={{ gap: 16 }}>
67
125
  {/* The step position is an eyebrow above the question (no progress bar — and
@@ -69,16 +127,47 @@ export function ClarifyWizard(props: ClarifyWizardProps) {
69
127
  as the question and answers. Keyed by index so each step's custom-answer
70
128
  draft is its own (no bleed across steps). */}
71
129
  <Clarify key={index} eyebrow={total > 1 ? `${index + 1} / ${total}` : undefined} question={q.question} options={q.answers} answer={current} onAnswer={setAnswer} allowCustom={q.allowCustom} />
72
- <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8, paddingHorizontal: 8 }}>
73
- <Button title={labels.cancel} color="muted" onPress={onCancel} />
74
- <View style={{ flexDirection: "row", gap: 8 }}>
75
- {index > 0 ? <Button title={labels.back} color="secondary" onPress={() => setIndex(index - 1)} /> : null}
76
- {isLast ? (
77
- <Button title={labels.submit} color="primary" onPress={submit} disabled={!answered} />
78
- ) : (
79
- <Button title={labels.next} color="primary" onPress={() => setIndex(index + 1)} disabled={!answered} />
80
- )}
130
+ {scope == null ? (
131
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8, paddingHorizontal: 8 }}>
132
+ <Button title={labels.cancel} color="muted" onPress={onCancel} />
133
+ <View style={{ flexDirection: "row", gap: 8 }}>
134
+ {index > 0 ? <Button title={labels.back} color="secondary" onPress={() => setIndex(index - 1)} /> : null}
135
+ {isLast ? (
136
+ <Button title={labels.submit} color="primary" onPress={submit} disabled={!answered} />
137
+ ) : (
138
+ <Button title={labels.next} color="primary" onPress={() => setIndex(index + 1)} disabled={!answered} />
139
+ )}
140
+ </View>
81
141
  </View>
142
+ ) : null}
143
+ </View>
144
+ );
145
+ }
146
+
147
+ /**
148
+ * The wizard's action bar for a `DialogFooter` — Cancel bottom-left,
149
+ * Back + Next/Submit right, the same verbs and gating as the inline row.
150
+ * Requires a `ClarifyWizardScope` ancestor; renders nothing until its
151
+ * `ClarifyWizard` mounts (so a multi-phase dialog can keep the footer JSX
152
+ * conditional on the clarify phase alone).
153
+ */
154
+ export function ClarifyWizardActions() {
155
+ const labels = useLoticsLocale().clarify;
156
+ const scope = useContext(ScopeContext);
157
+ if (scope == null) throw new Error("ClarifyWizardActions requires a ClarifyWizardScope ancestor.");
158
+ const h = scope.handle;
159
+ if (h == null) return null;
160
+ const isLast = h.index === h.total - 1;
161
+ return (
162
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8, flex: 1 }}>
163
+ <Button title={labels.cancel} color="muted" onPress={h.cancel} />
164
+ <View style={{ flexDirection: "row", gap: 8 }}>
165
+ {h.index > 0 ? <Button title={labels.back} color="secondary" onPress={h.back} /> : null}
166
+ {isLast ? (
167
+ <Button title={labels.submit} color="primary" onPress={h.submit} disabled={!h.answered} />
168
+ ) : (
169
+ <Button title={labels.next} color="primary" onPress={h.next} disabled={!h.answered} />
170
+ )}
82
171
  </View>
83
172
  </View>
84
173
  );
package/src/locale.tsx CHANGED
@@ -134,7 +134,7 @@ export interface LoticsLocale {
134
134
  * annotation on a call parked on a human decision, and the terminal error's
135
135
  * `retry` action. (Tool-step labels and the "{n} steps" suffix stay
136
136
  * prop-localized — `labelForCall` / `stepsLabel`.) */
137
- agentRun: { thinking: string; thinkingStreaming: string; input: string; error: string; output: string; awaiting: string; retry: string };
137
+ agentRun: { starting: string; thinking: string; thinkingStreaming: string; input: string; error: string; output: string; awaiting: string; retry: string };
138
138
  /** `ApprovalPrompt`: the default prompt line (overridable per instance) and
139
139
  * the Approve / Deny button labels — the surface that ANSWERS `AgentRun`'s
140
140
  * read-only `awaiting` row (approve/deny = the ai-sdk approval vocabulary). */
@@ -237,7 +237,7 @@ export const en: LoticsLocale = {
237
237
  matrix: { total: "Total", less: "Less", more: "More" },
238
238
  scrollToBottom: { tooltip: "Scroll to bottom" },
239
239
  textInputField: { clear: "Clear" },
240
- agentRun: { thinking: "Thinking", thinkingStreaming: "Thinking…", input: "Input", error: "Error", output: "Output", awaiting: "Awaiting", retry: "Retry" },
240
+ agentRun: { starting: "Starting…", thinking: "Thinking", thinkingStreaming: "Thinking…", input: "Input", error: "Error", output: "Output", awaiting: "Awaiting", retry: "Retry" },
241
241
  approvalPrompt: { message: "The assistant wants to perform an action that needs your approval.", approve: "Approve", deny: "Deny" },
242
242
  messageActions: { copy: "Copy", copied: "Copied", regenerate: "Regenerate", edit: "Edit", previousVersion: "Previous version", nextVersion: "Next version" },
243
243
  };
@@ -335,7 +335,7 @@ export const vi: LoticsLocale = {
335
335
  matrix: { total: "Tổng", less: "Ít", more: "Nhiều" },
336
336
  scrollToBottom: { tooltip: "Cuộn xuống cuối" },
337
337
  textInputField: { clear: "Xóa" },
338
- agentRun: { thinking: "Suy nghĩ", thinkingStreaming: "Đang suy nghĩ…", input: "Đầu vào", error: "Lỗi", output: "Kết quả", awaiting: "Chờ duyệt", retry: "Thử lại" },
338
+ agentRun: { starting: "Đang bắt đầu…", thinking: "Suy nghĩ", thinkingStreaming: "Đang suy nghĩ…", input: "Đầu vào", error: "Lỗi", output: "Kết quả", awaiting: "Chờ duyệt", retry: "Thử lại" },
339
339
  approvalPrompt: { message: "Trợ lý muốn thực hiện thao tác cần bạn duyệt.", approve: "Cho phép", deny: "Từ chối" },
340
340
  messageActions: { copy: "Sao chép", copied: "Đã sao chép", regenerate: "Tạo lại", edit: "Chỉnh sửa", previousVersion: "Phiên bản trước", nextVersion: "Phiên bản sau" },
341
341
  };