@lotics/ui 20.1.0 → 21.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.
package/AGENTS.md CHANGED
@@ -15,8 +15,8 @@ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
15
15
  | Doc | Read it for |
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
- | [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, the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
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 + the `ResultHeader` receipt), findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md). |
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 (the `FilesEditor` COMPOUND — root owns selection/gallery/confirm, you compose the bar, a HOST verb reads `useFilesEditorSelection` — plus the three-way file INTAKE: CTA + `FileDropTarget` + `usePasteFiles`), stage gates, the commit-on-blur vs action-press ordering law (the kit gates the press — `pending_commits`). |
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 + the `ResultHeader` receipt), findings, provenance, confidence; the UI half of the SDK's [ai doc](../app-sdk/docs/ai.md)., the whole run in a dialog (`AgentRunScope`/`AgentRunPane`/`AgentRunActions` — a parked question REPLACES the feed, actions in the footer) |
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
 
package/MIGRATION.md CHANGED
@@ -4,6 +4,63 @@ Breaking changes, newest first — normally per major, plus the rare minor that
4
4
  anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
5
5
  this file exists only to move an app from one release to the next.
6
6
 
7
+ ## v21 from 20.x
8
+
9
+ **`FilesEditor` is a COMPOUND.** It rendered a fixed toolbar — Upload · Select · Download all,
10
+ swapping into a select-mode row whose actions hid behind a generic "Menu" — and that shape cost
11
+ two things in real screens. A HOST verb had nowhere to go, so a surface that needed one (an AI
12
+ read over the picked papers, "send these to the broker") hand-rolled the whole grid and lost the
13
+ gallery, the upload queue and the confirm with it. And the actions that DID exist sat behind a
14
+ label naming a widget rather than an act, five interactions deep for "delete this scan".
15
+
16
+ The root still owns what a host cannot reasonably re-implement — the batch selection, the
17
+ full-screen gallery, the Alert-confirmed remove — and still renders the grid. What you can DO to
18
+ the files is now composed below it.
19
+
20
+ ```tsx
21
+ // BEFORE
22
+ <FilesEditor
23
+ files={files} uploads={uploads} onAdd={add} onRemove={remove}
24
+ onShareSelected={share} onDownloadZipSelected={zip} readOnly={readOnly}
25
+ />
26
+
27
+ // AFTER
28
+ <FilesEditor files={files} uploads={uploads} onAdd={readOnly ? undefined : add} onRemove={readOnly ? undefined : remove}>
29
+ <FilesEditorBar>
30
+ <FilesEditorUpload />
31
+ <FilesEditorSelect />
32
+ <FilesEditorSelectAll />
33
+ <FilesEditorBarSpacer />
34
+ <FilesEditorDownload />
35
+ <FilesEditorRemove />
36
+ </FilesEditorBar>
37
+ </FilesEditor>
38
+ ```
39
+
40
+ | Gone | Now |
41
+ |---|---|
42
+ | `onShareSelected` | a `Button` in the bar reading `useFilesEditorSelection()` — sharing is a HOST act |
43
+ | `onDownloadZipSelected` | the same |
44
+ | `readOnly` | withhold `onAdd`/`onRemove`; the pieces render nothing without a handler, so the composition IS the read-only shape and there is no second mode to keep in sync |
45
+ | the "Menu" popover | the bar. Actions are visible controls, in the order the surface wants them |
46
+ | `labels.menu` · `labels.share` · `labels.downloadZip` | deleted with the acts they named |
47
+
48
+ **No children means no bar** — a grid that previews and nothing else. That is the one rendering
49
+ path; there is no default toolbar to fall back to.
50
+
51
+ **The remaining labels resolve prop → `LoticsLocale.filesEditor` → nothing**, so an app that
52
+ supplies its pack at the root gets them translated with no per-instance wiring.
53
+
54
+ A host verb reads the selection:
55
+
56
+ ```tsx
57
+ function ReadWithAi() {
58
+ const { selected, exit } = useFilesEditorSelection();
59
+ return <Button title="Đọc bằng AI" disabled={selected.length === 0}
60
+ onPress={() => { run(selected); exit(); }} />;
61
+ }
62
+ ```
63
+
7
64
  ## v20 from 19.x
8
65
 
9
66
  **`@lotics/ui/section` is DELETED.** Two modules exported a `Section` — the layout grammar's
@@ -365,6 +365,44 @@ drives the footer bar (same verbs, same answered-gating). Worked example:
365
365
  [`tpl_item_list`](../examples/tpl_item_list.tsx)'s intake fork (the wizard between the analyze
366
366
  and import runs, its actions in the footer).
367
367
 
368
+ ## The whole run in a dialog — `AgentRunScope` / `AgentRunPane` / `AgentRunActions`
369
+
370
+ `@lotics/ui/agent_run_pane` is the two sections above already composed: the transcript while the
371
+ agent works, the question IN ITS PLACE when it asks, and the wizard's verbs pinned in the footer.
372
+ Reach for it whenever an agent run happens inside a dialog — which is nearly always. Three lines:
373
+
374
+ ```tsx
375
+ const run = useAgentRun("intake"); // @lotics/app-sdk
376
+ <AgentRunScope> {/* wraps the Dialog, like ClarifyWizardScope */}
377
+ <Dialog …>
378
+ <AgentRunPane run={run} onCancel={…} /> {/* content */}
379
+ <DialogFooter><AgentRunActions run={run} /></DialogFooter> {/* actions */}
380
+ </Dialog>
381
+ </AgentRunScope>
382
+ ```
383
+
384
+ **The parked question REPLACES the feed — that is the contract, not a style.** The run is blocked
385
+ on the answer, so the question is the only thing to act on; it gets the dialog's own scroller and
386
+ its actions sit outside it. Stacked under the transcript in an unscrollable box — the arrangement
387
+ every app reached for first — a multi-question ask runs past the dialog's height and clips its own
388
+ Submit, leaving a live `awaiting_input` run readable and unanswerable until it expires. The pane
389
+ also swaps `FollowScroll` for `DialogScrollArea` on the park, so the question opens at the top
390
+ rather than wherever the feed was scrolled to.
391
+
392
+ **`run` is a SHAPE, not an import** (`AgentRunLike`: `status`, `parts`, `pendingChoice`,
393
+ `answerChoice`, `error`; `AgentRunQuestion` names what `pendingChoice` carries, so a template or
394
+ test declares one directly — an option is a label + description and the answer's value IS the
395
+ label, which is the `ask_user_choice` wire shape, so nothing maps between two question types). `useAgentRun()` satisfies it structurally — `@lotics/ui` never depends on
396
+ `@lotics/app-sdk` — which also means a template or test can hand it a plain object and exercise the
397
+ whole pane with no backend ([`tpl_item_list`](../examples/tpl_item_list.tsx) does exactly that).
398
+
399
+ **Customize through the seams, or drop to the primitives.** `labelForCall` / `renderToolOutput`
400
+ pass through to `AgentRun`; the `run` object is the data seam. There is deliberately NO
401
+ empty-state slot — `AgentRun` renders its own localized "Starting…" row on zero parts, and the law
402
+ above forbids hand-rolling a placeholder. An app that wants a different ARRANGEMENT does not fight
403
+ the pane: `AgentRun`, `ClarifyWizard`, `ClarifyWizardScope`/`Actions`, `FollowScroll` and
404
+ `DialogScrollArea` all remain exported, and composing them is what this pane itself does.
405
+
368
406
  ## Provenance — `Sources`
369
407
 
370
408
  `Sources` (`@lotics/ui/sources`): openable chips saying where the output came FROM, under any
package/docs/catalog.md CHANGED
@@ -221,15 +221,12 @@ reference), `InfoPopover` (the ⓘ explainer).
221
221
 
222
222
  ### Files
223
223
 
224
- `FilesEditor` (THE all-in-one attachment field: an upload-aware grid + a toolbar below it
225
- that swaps into a batch SELECT mode, full-screen preview, download/share, and
226
- Alert-confirmed remove — the host only owns `files` + wires `onAdd`/`onRemove`. Reach for
227
- this first for "manage a record's attachments"), `FileDropzone`, `FileRows`
228
- (batteries-included file LIST: tap a row built-in full-screen gallery, with a per-row
229
- trailing menu = Download · Open-external · Remove; the default "here are some files"
230
- surface), `FileGrid` (the upload-aware grid: completed files + a live upload queue in one
231
- surface — `FilesEditor` is this + the toolbar; reach for `FileGrid` bare when you own the
232
- chrome), `FileThumbnail` / `FileThumbnailGrid` (square tiles; `onPress`/`onFilePress` makes each
224
+ `FilesEditor` (THE attachment surface: an upload-aware grid whose bar you COMPOSE — the root
225
+ owns selection + gallery + confirmed remove, the bar pieces and any HOST verb go below it via
226
+ `useFilesEditorSelection`),
227
+ `FileGrid` (the upload-aware grid: completed files + a live upload queue in one
228
+ surface `FilesEditor` is this + selection + the composed bar; reach for `FileGrid` bare when
229
+ you own all three), `FileThumbnail` / `FileThumbnailGrid` (square tiles; `onPress`/`onFilePress` makes each
233
230
  tile a pressable door — e.g. tap-to-preview — carrying an accessible button role + the
234
231
  filename as its name, overridable per tile with `accessibilityLabel` when what the press
235
232
  DOES reads better than a raw filename; `selectedIds` for a selection overlay),
@@ -1065,26 +1062,38 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1065
1062
  screenshot needs), `selectPasteSink(entries)` (the pure focus-then-stack routing rule the
1066
1063
  `.web` sink applies), and the types `FileIntakeFilter` / `FileTransferLike` / `RegionRef` /
1067
1064
  `PasteSinkEntry` / `UsePasteFilesOptions` / `FileDropTargetProps`.
1068
- - **`files_editor`** — `FilesEditor` THE all-in-one attachment field: `FileGrid` + a
1069
- toolbar (Upload primary · Select · Download all) that swaps into a batch SELECT mode
1070
- (Select all · a Menu of Download/Share/Delete · Done; the per-tile is select-mode-only,
1071
- never in the default view; `selectTileRemove={false}` drops even that so delete is
1072
- menu-only) + built-in gallery (Download + inline preview; no "open in new tab") +
1073
- Alert-confirmed remove; host wires `files` + `onAdd`/`onRemove` (+ optional `uploads`,
1074
- `onShareSelected`, `readOnly`, `selectTileRemove`, `labels`, `galleryLabels`,
1075
- `gridMaxHeight` cap the grid height so it scrolls and the toolbar pins, for a
1076
- popover/drawer). Use `FileGrid`/`FileRows` bare only when you own the chrome.
1065
+ - **`files_editor`** — THE attachment surface, as a **compound**. `FilesEditor` (root) owns
1066
+ what a host cannot reasonably re-implement the batch selection, the built-in gallery
1067
+ (Download + inline preview; no "open in new tab"), the Alert-confirmed remove and renders
1068
+ `FileGrid`; the host wires `files` + `onAdd`/`onRemove` (+ optional `uploads`,
1069
+ `selectTileRemove`, `labels`, `galleryLabels`, `gridMaxHeight` cap the grid height so it
1070
+ scrolls and the bar pins, for a popover/drawer). **What you can DO to the files is composed
1071
+ below it**, from `FilesEditorBar` + `FilesEditorUpload` · `FilesEditorSelect` ·
1072
+ `FilesEditorSelectAll` · `FilesEditorDownload` · `FilesEditorRemove` (+
1073
+ `FilesEditorBarSpacer` to push the rest right). Each renders nothing without the handler it
1074
+ needs, so withholding `onAdd`/`onRemove` IS the read-only shape — there is no `readOnly`
1075
+ mode. **A HOST verb is a plain `Button`** reading **`useFilesEditorSelection()`**
1076
+ (`{selected, selectedIds, files, selectMode, clear, exit}`) — an AI read over the picked
1077
+ papers, "send to the broker", a ZIP: acts the kit has never heard of, which is why there is
1078
+ no props-per-act toolbar and no generic Menu. **No children means no bar** (a grid that only
1079
+ previews). Bar words resolve prop → `LoticsLocale.filesEditor`; in-flight tile words →
1080
+ `LoticsLocale.fileUpload`. **No empty state:** with zero files it is a bare bar, so pair it
1081
+ with a `FileDropzone` for the well a records screen opens on. Use `FileGrid`/`FileRows` bare
1082
+ only when you own the selection and the gallery too.
1077
1083
  - **`file_grid`** — `FileGrid` + the `FileUpload`/`PendingUpload` types: the upload-aware
1078
1084
  grid — `files` are the saved/completed `DisplayFile`s, `uploads` is the LIVE add-queue; it
1079
1085
  interleaves both and renders each in-flight tile itself with a labeled status overlay.
1080
1086
  Tiles FILL the container width at a uniform size (≥ `minItemWidth`, default 96); pass
1081
1087
  `columns` for a fixed count, `itemSize` for exact tiles, or `singleRow` to fit one row and
1082
1088
  collapse the rest into a clickable "+N" overflow tile (`onOverflowPress(hiddenCount)`).
1083
- CRUD via `onFilePress` / `onDisplayRemove` / `onUploadRemove` / `onRetry` / `onRetryAll`;
1084
- localize with `labels.upload` (an `UploadStatusLabels`).
1089
+ CRUD via `onFilePress` / `onDisplayRemove` / `onUploadRemove` / `onRetry` / `onRetryAll`.
1090
+ The status words resolve **prop → `LoticsLocale.fileUpload` nothing**, so they follow the
1091
+ root pack with no per-call-site wiring (that is also how the grid inside `FilesEditor`, which
1092
+ cannot pass `labels`, gets localized); `labels.upload`/`labels.retryAll` override one grid.
1085
1093
  - **`uploading_thumbnail`** — `UploadingThumbnail` + `UploadStatus`/`UploadStatusLabels`:
1086
1094
  the single in-flight upload tile `FileGrid` renders; reach for it only when hand-rolling a
1087
- non-grid upload layout.
1095
+ non-grid upload layout — it is i18n-free by design, so a bare call site owes it `labels`
1096
+ (`FileGrid` resolves them from `LoticsLocale.fileUpload` on your behalf).
1088
1097
  - **`file_thumbnail`** — `FileThumbnail` + `DisplayFile` + `THUMBNAIL_SIZE` /
1089
1098
  `COMPACT_THUMBNAIL_SIZE` + `getMediaIcon`: the completed tile — the right surface per
1090
1099
  MIME: image thumbnail · a doc tile with the `FileBadge` centered + a single-line filename
@@ -1224,6 +1233,18 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1224
1233
  with **`ClarifyWizardScope`** and put **`ClarifyWizardActions`** in the `DialogFooter` — the
1225
1234
  wizard suppresses the inline row and drives the footer bar (same verbs, same gating), per the
1226
1235
  dialog grammar's footer-owns-actions law (worked example: `tpl_item_list`'s clarify phase).
1236
+ - **`agent_run_pane`** — `AgentRunScope` + `AgentRunPane` + `AgentRunActions` (+ `AgentRunLike`/`AgentRunQuestion`/`AgentRunOption`):
1237
+ a whole agent run inside a dialog, in three lines. The pane streams `AgentRun` while the agent
1238
+ works and, the moment it asks, renders the question IN PLACE OF the feed — in the dialog's own
1239
+ scroller, with the wizard's verbs pinned in the `DialogFooter` via `AgentRunActions`. Replacing
1240
+ rather than stacking is the CONTRACT: stacked under the transcript a multi-question ask clips
1241
+ its own Submit and strands a live `awaiting_input` run. `run` is a structural shape
1242
+ (`AgentRunLike`), so `useAgentRun()` satisfies it with no `@lotics/app-sdk` dependency and a
1243
+ mock satisfies it with no backend — `AgentRunQuestion` names the question shape so a template
1244
+ or test DECLARES one rather than mapping from `ClarifyWizardQuestion` (an option is a label +
1245
+ description; the answer's value IS the label, the `ask_user_choice` wire shape). Seams:
1246
+ `labelForCall` / `renderToolOutput`; no empty-state slot by design (`AgentRun` owns the
1247
+ localized "Starting…" row). Worked example: `tpl_item_list`'s intake dialog.
1227
1248
  - **`choice_list`** — `ChoiceList` + `ChoiceOption`: selectable answer options as
1228
1249
  divider-separated rows (no bordered cards) with a per-row focus ring + hover wash; the
1229
1250
  agent's quick-reply surface. `allowCustom` appends an always-visible borderless multiline field whose
@@ -514,28 +514,59 @@ affordance line under its heading — **`<SectionHeadingTitle description="Drag,
514
514
  to add files">`** (both templates do this). A `FileDropzone`'s own hint already names the paste
515
515
  (its default `fileDropzone.hint` is "or click, or paste (⌘V)").
516
516
 
517
- ### Default: `FilesEditor`
518
-
519
- **`<FilesEditor files onAdd onRemove>`** bundles the upload-aware grid + a toolbar below it
520
- (Upload · Select · Download all) + a batch SELECT mode + the full-screen gallery +
521
- Alert-confirmed remove; the host only owns `files` and wires `onAdd` (picked its upload) /
522
- `onRemove`. The destructive per-tile shows only in SELECT mode (the default view is a clean
523
- preview — no stray-tap deletes); pass `selectTileRemove={false}` to drop that ✕ entirely so
524
- select mode deletes ONLY via Select → Menu → Delete (the batch gating flow — right for a
525
- height-bounded cell editor). The built-in gallery is Download + inline preview (no "open in new
526
- tab" — it's redundant once everything previews inline). In a height-bounded container (a
527
- popover/drawer) pass `gridMaxHeight` so the grid SCROLLS and the toolbar pins below it; omit it
528
- in free-flow layouts (a form field) where the grid grows.
517
+ ### Default: the `FilesEditor` compound
518
+
519
+ **The root owns what a host cannot reasonably re-implement** the batch selection, the
520
+ full-screen gallery, the Alert-confirmed remove and renders the upload-aware grid. **What you
521
+ can DO to the files is composed below it**, so a surface offers exactly its own verbs, in its
522
+ own order, under their own names.
523
+
524
+ ```tsx
525
+ <FilesEditor files={files} uploads={queue} onAdd={add} onRemove={remove}>
526
+ <FilesEditorBar>
527
+ <FilesEditorUpload />
528
+ <FilesEditorSelect /> {/* "Select" "Done" one toggle, not two buttons */}
529
+ <FilesEditorSelectAll /> {/* renders only while selecting */}
530
+ <FilesEditorBarSpacer />
531
+ <FilesEditorDownload /> {/* the selection while selecting, everything at rest */}
532
+ <FilesEditorRemove />
533
+ </FilesEditorBar>
534
+ </FilesEditor>
535
+ ```
536
+
537
+ **A HOST verb is a plain `Button`** that reads `useFilesEditorSelection()` →
538
+ `{selected, selectedIds, files, selectMode, clear, exit}`. This is the whole reason the bar is
539
+ composed: an act the kit has never heard of — read these with AI, send them to the broker, ZIP
540
+ them — has a home, instead of forcing the screen to hand-roll the grid and lose the gallery and
541
+ the upload queue with it.
542
+
543
+ ```tsx
544
+ function ReadWithAi() {
545
+ const { selected, exit } = useFilesEditorSelection();
546
+ return <Button title="Read with AI" disabled={selected.length === 0}
547
+ onPress={() => { run(selected); exit(); }} />;
548
+ }
549
+ ```
550
+
551
+ **Each piece renders nothing without the handler it needs**, so composition expresses the
552
+ variants that used to be flags: withhold `onAdd` and there is no Upload; withhold `onRemove` and
553
+ there is no Remove and no per-tile ✕ — that IS read-only, with no second mode to keep in sync.
554
+ **No children means no bar**: a grid that previews and nothing else.
555
+
556
+ The destructive per-tile ✕ shows only in SELECT mode (the default view is a clean preview — no
557
+ stray-tap deletes); `selectTileRemove={false}` drops it there too, so the bar's Remove is the
558
+ only delete path (right for a height-bounded cell editor). The built-in gallery is Download +
559
+ inline preview (no "open in new tab" — redundant once everything previews inline). In a
560
+ height-bounded container (a popover/drawer) pass `gridMaxHeight` so the grid SCROLLS and the bar
561
+ pins below it; omit it in free-flow layouts where the grid grows.
529
562
 
530
563
  The rest of the surface: `uploads` passes the live add-queue through to the grid (with
531
- `onUploadRemove`/`onRetry`/`onRetryAll`); `onUpload` overrides the Upload action's built-in web
532
- picker (e.g. a native document picker); `readOnly` (or omitting `onRemove`) makes it a view-only
533
- download+preview surface; omitting `onAdd` hides Upload. Select mode gains a Share action via
534
- `onShareSelected(files)` and a "Download as ZIP" item via `onDownloadZipSelected(files)` (shown at
535
- 2+ selected) both host-provided, hidden when omitted. `onDownload` overrides the default
536
- per-file `downloadFileFromUrl`; `accept` filters the picker; `itemSize`/`minItemWidth`/`columns`
537
- pass through to the grid; `credentials` covers auth-gated preview URLs; `labels` /
538
- `galleryLabels` localize the toolbar and the gallery chrome.
564
+ `onUploadRemove`/`onRetry`/`onRetryAll`); `onUpload` overrides the Upload picker (e.g. a native
565
+ document picker); `onDownload` overrides the default per-file `downloadFileFromUrl`; `accept`
566
+ filters the picker; `itemSize`/`minItemWidth`/`columns` pass through to the grid; `credentials`
567
+ covers auth-gated preview URLs. Bar words resolve **prop `LoticsLocale.filesEditor`**, gallery
568
+ chrome via `galleryLabels`, in-flight tile words via `LoticsLocale.fileUpload` so an app that
569
+ supplies its pack at the root needs no per-instance labels at all.
539
570
 
540
571
  Reach for the lower-level pieces below only when you need custom chrome.
541
572
 
@@ -551,9 +582,10 @@ more" affordance — only the empty state leads with it).
551
582
  the LIVE add-queue (`FileUpload[]`) — it interleaves both and renders each in-flight tile itself —
552
583
  a LABELED status overlay (uploading spinner · "Retrying" · "Paused" · "Upload failed" + a retry
553
584
  button · "Can't upload" for a dead/empty file) — so you never hand-map an upload to a
554
- `FileThumbnail`. Localize the labels with `labels.upload` (an `UploadStatusLabels`) and
555
- `labels.retryAll`. **Limitation:** the upload-status labels are NOT wired to
556
- `LoticsLocaleProvider` they default to English; pass `labels` per instance to localize.
585
+ `FileThumbnail`. Those words resolve **prop `LoticsLocale.fileUpload` nothing** — set the
586
+ pack once at the root and a stalled or dead upload speaks the app's language everywhere,
587
+ including inside `FilesEditor`, which has no per-instance way to reach them. `labels.upload` /
588
+ `labels.retryAll` override one grid.
557
589
 
558
590
  Make it CRUDable by wiring its callbacks: `onFilePress` → set a `number|null` index that drives
559
591
  `<FileGalleryModal files activeIndex onIndexChange>` — a FULL-SCREEN viewer with a toolbar
@@ -52,15 +52,12 @@ import { FileGalleryModal } from "@lotics/ui/file_gallery_modal";
52
52
  import { Ledger, LedgerGroup, LedgerRow, LedgerTotal } from "@lotics/ui/ledger";
53
53
  import { ProgressBar } from "@lotics/ui/progress_bar";
54
54
  import { Dialog, DialogFooter, DialogHeader, DialogHeaderTitle, DialogScrollArea } from "@lotics/ui/dialog";
55
- import { AgentRun } from "@lotics/ui/agent_run";
56
- import { FollowScroll } from "@lotics/ui/follow_scroll";
57
- import { ClarifyWizard, ClarifyWizardActions, ClarifyWizardScope, type ClarifyWizardAnswer, type ClarifyWizardQuestion } from "@lotics/ui/clarify_wizard";
55
+ import { AgentRunScope, AgentRunPane, AgentRunActions, type AgentRunLike, type AgentRunQuestion } from "@lotics/ui/agent_run_pane";
58
56
  import { ResultHeader } from "@lotics/ui/result_header";
59
57
  import { Confidence } from "@lotics/ui/confidence";
60
58
  import { CardSelectItem } from "@lotics/ui/card_select_item";
61
59
  import { FileDropzone } from "@lotics/ui/file_dropzone";
62
60
  import { FileDropTarget } from "@lotics/ui/file_drop_target";
63
- import { useScreenSize } from "@lotics/ui/use_screen_size";
64
61
  import { MemberSelect } from "@lotics/ui/member_select";
65
62
  import { Callout, CalloutText } from "@lotics/ui/callout";
66
63
  import { Timeline, type TimelineItem } from "@lotics/ui/timeline";
@@ -1052,8 +1049,8 @@ function LinkedRecordScreen({ ma }: { ma: string }) {
1052
1049
 
1053
1050
  // ─── Enter data — the INTAKE fork ────────────────────────────────────────────
1054
1051
  // AI FIRST, form as fallback. The one "Enter data" CTA opens a phased dialog:
1055
- // drop files (the hero) → a short ANALYZE stream reads them → `ClarifyWizard`
1056
- // asks the genuine ambiguities the analysis surfaced → the IMPORT stream
1052
+ // drop files (the hero) → a short ANALYZE stream reads them → the run asks the
1053
+ // genuine ambiguities the analysis surfaced → the IMPORT stream
1057
1054
  // CREATES the records and the dialog ends on the RESULT LIST — one row per
1058
1055
  // record with its key figures. This is the one law's creation branch: no
1059
1056
  // review gate on a create (nothing to diff — the register IS the review);
@@ -1064,7 +1061,8 @@ function LinkedRecordScreen({ ma }: { ma: string }) {
1064
1061
  // create-then-refine gate). The mock plays the clarify step as two phases; a
1065
1062
  // real app can run it as ONE agent run — every app agent carries
1066
1063
  // `ask_user_choice`, so the run parks on the agent's own question and
1067
- // `useAgentRun().pendingChoice`/`answerChoice` drive this same wizard.
1064
+ // `AgentRunPane` renders it from `useAgentRun()` with no change to this file's
1065
+ // shape: the mock below satisfies the same `AgentRunLike` the hook does.
1068
1066
 
1069
1067
  type Part = UIMessagePart<UIDataTypes, UITools>;
1070
1068
  type IntakePhase = "intake" | "analyze" | "clarify" | "running" | "done" | "form";
@@ -1085,20 +1083,23 @@ const PROPOSED_BY_ORDER: Proposal[] = PROPOSED_BY_CUSTOMER.flatMap((c) =>
1085
1083
 
1086
1084
  // The wizard's questions come FROM the analysis (a real app renders them off the
1087
1085
  // analyze run's structured output) — informed, described, one custom-answer slot.
1088
- const INTAKE_QUESTIONS: ClarifyWizardQuestion[] = [
1086
+ // Declared in the shape `pendingChoice` actually carries, so this reads like a
1087
+ // real run: an option is a LABEL plus its description, and the answer's value IS
1088
+ // that label (the `ask_user_choice` wire shape — there is no separate code).
1089
+ const INTAKE_QUESTIONS: AgentRunQuestion[] = [
1089
1090
  {
1090
1091
  question: "6 orders across 3 customers. How should they become records?",
1091
- answers: [
1092
- { value: "customer", label: "One record per customer", description: "3 records — each customer's orders grouped into one workspace and checklist." },
1093
- { value: "order", label: "One record per order", description: "6 records — every order tracked on its own; more rows, finer-grained status." },
1092
+ options: [
1093
+ { label: "One record per customer", description: "3 records — each customer's orders grouped into one workspace and checklist." },
1094
+ { label: "One record per order", description: "6 records — every order tracked on its own; more rows, finer-grained status." },
1094
1095
  ],
1095
1096
  },
1096
1097
  {
1097
1098
  question: "Blue Harbor Foods isn't in the customer book yet. What should happen?",
1098
- allowCustom: true,
1099
- answers: [
1100
- { value: "create", label: "Create the customer", description: "A new customer record is added and linked as the records land." },
1101
- { value: "skip", label: "Leave unassigned", description: "The records are created without a customer — link one later from each record." },
1099
+ allow_custom: true,
1100
+ options: [
1101
+ { label: "Create the customer", description: "A new customer record is added and linked as the records land." },
1102
+ { label: "Leave unassigned", description: "The records are created without a customer — link one later from each record." },
1102
1103
  ],
1103
1104
  },
1104
1105
  ];
@@ -1137,7 +1138,6 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
1137
1138
  /** The import's save — rows land in the register as the stream finishes. */
1138
1139
  onCreateMany: (records: { khach: string; dienThoai: string; phi: number }[]) => void;
1139
1140
  }) {
1140
- const { small } = useScreenSize();
1141
1141
  const [phase, setPhase] = useState<IntakePhase>("intake");
1142
1142
  const [docs, setDocs] = useState<DisplayFile[]>([]);
1143
1143
  const [variant, setVariant] = useState<ManualVariant>("export");
@@ -1172,6 +1172,32 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
1172
1172
  const docNames = docs.map((d) => d.filename);
1173
1173
  const [revealed, setRevealed] = useState(0);
1174
1174
  const script = phase === "analyze" ? analyzeScript(docNames) : phase === "running" ? importScript(grouping) : [];
1175
+
1176
+ // What a real app hands the pane: `const run = useAgentRun("intake")`. The
1177
+ // template has no backend, so it satisfies the same shape from its script —
1178
+ // which is the point, the pane never knows the difference.
1179
+ const run: AgentRunLike = {
1180
+ status: phase === "clarify" ? "awaiting_input" : revealed >= script.length ? "completed" : "streaming",
1181
+ parts: script.slice(0, revealed),
1182
+ error: null,
1183
+ pendingChoice:
1184
+ phase === "clarify"
1185
+ ? { questions: INTAKE_QUESTIONS }
1186
+ : null,
1187
+ answerChoice: async (answers) => {
1188
+ // The answer's value is the LABEL the human picked — match on it.
1189
+ setGrouping(answers[0]?.value === "One record per order" ? "order" : "customer");
1190
+ const plan = answers[1];
1191
+ setCustomerPlan(
1192
+ plan?.custom
1193
+ ? plan.value
1194
+ : plan?.value === "Leave unassigned"
1195
+ ? "Left unassigned — link a customer later"
1196
+ : "New customer created and linked",
1197
+ );
1198
+ setPhase("running");
1199
+ },
1200
+ };
1175
1201
  useEffect(() => {
1176
1202
  if (phase !== "analyze" && phase !== "running") return;
1177
1203
  setRevealed(0);
@@ -1244,14 +1270,14 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
1244
1270
  : "Import from files";
1245
1271
 
1246
1272
  return (
1247
- <ClarifyWizardScope>
1273
+ <AgentRunScope>
1248
1274
  {/* the file/stream phases (intake/analyze/clarify/running/done) run
1249
- WIDE — room for the thumbnail hero + the AgentRun/ClarifyWizard/result
1275
+ WIDE — room for the thumbnail hero + the AgentRunPane/result
1250
1276
  panes; only the manual `form` stays a narrow single-column pane.
1251
1277
  760 sits just under the kit Dialog's default 786 maxWidth cap.
1252
- ClarifyWizardScope wraps the Dialog from OUTSIDE so the wizard's
1253
- Cancel/Back/Next/Submit render in the DialogFooter — the dialog
1254
- grammar's home for action bars — via ClarifyWizardActions. */}
1278
+ AgentRunScope wraps the Dialog from OUTSIDE so the run's own action
1279
+ bar renders in the DialogFooter — the dialog grammar's home for
1280
+ action bars — via AgentRunActions. */}
1255
1281
  <Dialog width={phase === "form" ? 480 : 760} open={open} onOpenChange={(o) => { if (!o) close(); }}>
1256
1282
  <DialogHeader>
1257
1283
  <DialogHeaderTitle>{title}</DialogHeaderTitle>
@@ -1324,33 +1350,12 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
1324
1350
  </FileDropTarget>
1325
1351
  ) : null}
1326
1352
 
1327
- {phase === "analyze" || phase === "running" ? (
1328
- // Streams follow in a FollowScroll (padding mirrors DialogScrollArea);
1329
- // the phase flip swaps the scroller out, so the next pane opens at the top.
1330
- <FollowScroll contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: small ? 16 : 24 }}>
1331
- <AgentRun parts={script.slice(0, revealed)} state={revealed >= script.length ? "done" : "streaming"} />
1332
- </FollowScroll>
1333
- ) : null}
1334
-
1335
- {phase === "clarify" ? (
1336
- <View style={{ paddingHorizontal: 24, paddingBottom: 20 }}>
1337
- <ClarifyWizard
1338
- questions={INTAKE_QUESTIONS}
1339
- onCancel={() => setPhase("intake")}
1340
- onSubmit={(answers: ClarifyWizardAnswer[]) => {
1341
- setGrouping(answers[0]?.value === "order" ? "order" : "customer");
1342
- const plan = answers[1];
1343
- setCustomerPlan(
1344
- plan?.custom
1345
- ? plan.value
1346
- : plan?.value === "skip"
1347
- ? "Left unassigned — link a customer later"
1348
- : "New customer created and linked",
1349
- );
1350
- setPhase("running");
1351
- }}
1352
- />
1353
- </View>
1353
+ {phase === "analyze" || phase === "running" || phase === "clarify" ? (
1354
+ // ONE pane for the whole run. It streams the transcript, and when the
1355
+ // agent asks, the question REPLACES the feed scrolled, with its
1356
+ // actions in the footer. A real app passes `useAgentRun(alias)`
1357
+ // straight in; this mock is the same shape.
1358
+ <AgentRunPane run={run} onCancel={() => setPhase("intake")} />
1354
1359
  ) : null}
1355
1360
 
1356
1361
  {phase === "done" ? (
@@ -1462,7 +1467,7 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
1462
1467
  </DialogFooter>
1463
1468
  ) : phase === "clarify" ? (
1464
1469
  <DialogFooter>
1465
- <ClarifyWizardActions />
1470
+ <AgentRunActions run={run} />
1466
1471
  </DialogFooter>
1467
1472
  ) : phase === "done" ? (
1468
1473
  <DialogFooter>
@@ -1471,7 +1476,7 @@ function EnterDataDialog({ open, onOpenChange, seedDocs, onCreate, onCreateMany
1471
1476
  </DialogFooter>
1472
1477
  ) : null}
1473
1478
  </Dialog>
1474
- </ClarifyWizardScope>
1479
+ </AgentRunScope>
1475
1480
  );
1476
1481
  }
1477
1482
 
@@ -21,7 +21,7 @@ import { ActionMenu } from "@lotics/ui/action_menu";
21
21
  import { Alert } from "@lotics/ui/alert";
22
22
  import { pickFiles } from "@lotics/ui/file_picker";
23
23
  import { Popover, PopoverTrigger, PopoverContent } from "@lotics/ui/popover";
24
- import { FilesEditor } from "@lotics/ui/files_editor";
24
+ import { FilesEditor, FilesEditorBar, FilesEditorBarSpacer, FilesEditorRemove, FilesEditorSelect, FilesEditorUpload } from "@lotics/ui/files_editor";
25
25
  import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
26
26
  import { DataGrid, gridRowStyle, type DataGridColumn, type DataGridGroup } from "@lotics/ui/data_grid";
27
27
  import { CONTROL_RADIUS } from "@lotics/ui/control_surface";
@@ -483,7 +483,16 @@ function FilesCell({ files, onAdd, onRemove }: { files: DisplayFile[]; onAdd: (p
483
483
  </PopoverTrigger>
484
484
  <PopoverContent>
485
485
  <View style={{ width: 340, padding: 8 }}>
486
- <FilesEditor files={files} itemSize={76} onAdd={onAdd} onRemove={onRemove} />
486
+ {/* The bar is composed, so this popover offers exactly the three acts
487
+ a task attachment needs and nothing else. */}
488
+ <FilesEditor files={files} itemSize={76} onAdd={onAdd} onRemove={onRemove}>
489
+ <FilesEditorBar>
490
+ <FilesEditorUpload />
491
+ <FilesEditorSelect />
492
+ <FilesEditorBarSpacer />
493
+ <FilesEditorRemove />
494
+ </FilesEditorBar>
495
+ </FilesEditor>
487
496
  </View>
488
497
  </PopoverContent>
489
498
  </Popover>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "20.1.0",
3
+ "version": "21.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -102,6 +102,7 @@
102
102
  "./finding": "./src/finding.tsx",
103
103
  "./clarify": "./src/clarify.tsx",
104
104
  "./clarify_wizard": "./src/clarify_wizard.tsx",
105
+ "./agent_run_pane": "./src/agent_run_pane.tsx",
105
106
  "./result_header": "./src/result_header.tsx",
106
107
  "./choice_list": "./src/choice_list.tsx",
107
108
  "./sources": "./src/sources.tsx",