@lotics/ui 20.0.2 → 20.2.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
@@ -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, 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). |
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
 
@@ -157,9 +157,10 @@ PUT and rolls the calls out BELOW it on press. The run ALWAYS ends on the agent'
157
157
  no global terminal node.
158
158
 
159
159
  **Tool labels.** A tool part carries the RAW tool name (`update_records` from `tool-update_records`,
160
- or a `dynamic-tool`'s `toolName`), resolved via a built-in map of the platform record/document
161
- tools + an optional **`labelForCall`** override (localize THERE the kit stays English). It gets
162
- the whole call `{ toolName, input, state }` (`AgentToolCall`) so a caller can phrase by what the
160
+ or a `dynamic-tool`'s `toolName`), resolved through the active locale's **`agentRun.tools`** map
161
+ so a `vi` provider renders Vietnamese tool rows with no per-app wiring, the same way the rest of the
162
+ feed's chrome already resolved. An optional **`labelForCall`** override still wins, for phrasing a
163
+ call by what it TARGETS rather than by which tool ran. It gets the whole call — `{ toolName, input, state }` (`AgentToolCall`) — so a caller can phrase by what the
163
164
  call targets or by whether it's awaiting; return `undefined` to fall back. Unknown names fall back
164
165
  to a prettified form. `stepsLabel` localizes the "{n} steps" suffix. The tool `state` folds ai's
165
166
  7-state lifecycle to **running / awaiting / done / error**: `approval-requested` → `awaiting` (the
@@ -206,8 +207,12 @@ feed**; the starting row IS the placeholder, consistent and localized.
206
207
  **Localization.** The feed's fixed chrome — the "Thinking" label, the "Input"/"Output"/"Error"
207
208
  panel titles, the "awaiting" annotation, and the "Retry" action — resolves through the
208
209
  `LoticsLocale` `agentRun` slice (translate once at the provider; a `vi` app gets them for free).
209
- It is provider-localized, not per-instance prop-overridable; `labelForCall`/`stepsLabel` localize
210
- the tool labels and the "{n} steps" suffix per call site.
210
+ **and so are the tool labels** (`agentRun.tools`, keyed by raw tool name). Translating a tool
211
+ row is no longer each app's job: an English row under a Vietnamese "Đang suy nghĩ…" was the kit
212
+ contradicting itself. The "{n} steps" suffix is locale-backed too (`agentRun.steps`). `labelForCall`
213
+ and `stepsLabel` remain as per-call-site OVERRIDES — for phrasing a row by what
214
+ it targets — not as the only route to a translation. A tool name in neither the locale map nor
215
+ `labelForCall` falls back to its prettified form.
211
216
 
212
217
  **Answering an awaiting call — `ApprovalPrompt`.** The `awaiting` row inside `AgentRun` is
213
218
  READ-ONLY — it only NAMES that a call is parked on a human decision. The surface that ANSWERS it
@@ -360,6 +365,44 @@ drives the footer bar (same verbs, same answered-gating). Worked example:
360
365
  [`tpl_item_list`](../examples/tpl_item_list.tsx)'s intake fork (the wizard between the analyze
361
366
  and import runs, its actions in the footer).
362
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
+
363
406
  ## Provenance — `Sources`
364
407
 
365
408
  `Sources` (`@lotics/ui/sources`): openable chips saying where the output came FROM, under any
package/docs/catalog.md CHANGED
@@ -1224,6 +1224,18 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1224
1224
  with **`ClarifyWizardScope`** and put **`ClarifyWizardActions`** in the `DialogFooter` — the
1225
1225
  wizard suppresses the inline row and drives the footer bar (same verbs, same gating), per the
1226
1226
  dialog grammar's footer-owns-actions law (worked example: `tpl_item_list`'s clarify phase).
1227
+ - **`agent_run_pane`** — `AgentRunScope` + `AgentRunPane` + `AgentRunActions` (+ `AgentRunLike`/`AgentRunQuestion`/`AgentRunOption`):
1228
+ a whole agent run inside a dialog, in three lines. The pane streams `AgentRun` while the agent
1229
+ works and, the moment it asks, renders the question IN PLACE OF the feed — in the dialog's own
1230
+ scroller, with the wizard's verbs pinned in the `DialogFooter` via `AgentRunActions`. Replacing
1231
+ rather than stacking is the CONTRACT: stacked under the transcript a multi-question ask clips
1232
+ its own Submit and strands a live `awaiting_input` run. `run` is a structural shape
1233
+ (`AgentRunLike`), so `useAgentRun()` satisfies it with no `@lotics/app-sdk` dependency and a
1234
+ mock satisfies it with no backend — `AgentRunQuestion` names the question shape so a template
1235
+ or test DECLARES one rather than mapping from `ClarifyWizardQuestion` (an option is a label +
1236
+ description; the answer's value IS the label, the `ask_user_choice` wire shape). Seams:
1237
+ `labelForCall` / `renderToolOutput`; no empty-state slot by design (`AgentRun` owns the
1238
+ localized "Starting…" row). Worked example: `tpl_item_list`'s intake dialog.
1227
1239
  - **`choice_list`** — `ChoiceList` + `ChoiceOption`: selectable answer options as
1228
1240
  divider-separated rows (no bordered cards) with a per-row focus ring + hover wash; the
1229
1241
  agent's quick-reply surface. `allowCustom` appends an always-visible borderless multiline field whose
@@ -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
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "20.0.2",
3
+ "version": "20.2.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",
@@ -6,6 +6,7 @@ import { WaveAvatar } from "./wave_avatar";
6
6
  import { FollowScroll } from "./follow_scroll";
7
7
  import { PressableHighlight } from "./pressable_highlight";
8
8
  import { AgentRun, resolveToolMeta, type AgentRunProps, type AgentToolCall } from "./agent_run";
9
+ import { useLoticsLocale } from "./locale";
9
10
  import { toSegments, lastRunningStep, type AgentUIPart } from "./agent_transform";
10
11
 
11
12
  export interface AgentProgressProps {
@@ -43,7 +44,14 @@ export function AgentProgress(props: AgentProgressProps) {
43
44
  const [expanded, setExpanded] = useState(defaultExpanded ?? false);
44
45
 
45
46
  const running = lastRunningStep(toSegments(parts));
46
- const runningLabel = running ? resolveToolMeta({ toolName: running.toolName, input: running.input, state: running.status }, labelForCall).label : undefined;
47
+ const locale = useLoticsLocale();
48
+ const runningLabel = running
49
+ ? resolveToolMeta(
50
+ { toolName: running.toolName, input: running.input, state: running.status },
51
+ labelForCall,
52
+ locale.agentRun.tools,
53
+ ).label
54
+ : undefined;
47
55
  const compact = label ?? runningLabel ?? (state === "done" ? "Done" : state === "error" ? "Stopped" : "Working…");
48
56
  const streaming = state === "streaming";
49
57
 
package/src/agent_run.tsx CHANGED
@@ -69,19 +69,28 @@ export interface AgentRunProps {
69
69
  }
70
70
 
71
71
  // ── Tool → display meta ───────────────────────────────────────────────────────
72
- // The bounded set of platform tools an app agent can call. English by default
73
- // (the kit is locale-neutral; apps localize via `labelForCall`). A tool the map
74
- // doesn't know falls back to a prettified name + a neutral icon.
75
- const TOOL_META: Record<string, { label: string; icon: IconName }> = {
76
- query_records: { label: "Searching records", icon: "search" },
77
- get_record: { label: "Reading a record", icon: "file-text" },
78
- get_records: { label: "Reading records", icon: "file-text" },
79
- create_records: { label: "Creating records", icon: "plus" },
80
- update_records: { label: "Updating records", icon: "square-pen" },
81
- delete_records: { label: "Removing records", icon: "trash" },
82
- generate_pdf_from_template: { label: "Generating PDF", icon: "file-down" },
83
- generate_excel_from_template: { label: "Generating spreadsheet", icon: "file-spreadsheet" },
84
- generate_docx_from_template: { label: "Generating document", icon: "file-text" },
72
+ // The ICON per platform tool. The LABEL lives in the locale (`agentRun.tools`)
73
+ // because it is language, and this component already renders localized chrome
74
+ // around these rows an English tool row under a Vietnamese "Đang suy nghĩ…"
75
+ // was the kit contradicting itself. An icon is not language, so it stays here.
76
+ // A tool neither map knows falls back to a prettified name + a neutral icon.
77
+ const TOOL_ICONS: Record<string, IconName> = {
78
+ query_records: "search",
79
+ get_record: "file-text",
80
+ get_records: "file-text",
81
+ create_records: "plus",
82
+ update_records: "square-pen",
83
+ delete_records: "trash",
84
+ run_app_query: "search",
85
+ run_app_workflow: "play",
86
+ grep_knowledge: "search",
87
+ read_knowledge: "book-open",
88
+ list_knowledge: "list",
89
+ view_files: "file-text",
90
+ ask_user_choice: "message-circle-question-mark",
91
+ generate_pdf_from_template: "file-down",
92
+ generate_excel_from_template: "file-spreadsheet",
93
+ generate_docx_from_template: "file-text",
85
94
  };
86
95
 
87
96
  function prettifyToolName(name: string): string {
@@ -90,26 +99,37 @@ function prettifyToolName(name: string): string {
90
99
  }
91
100
 
92
101
  /** Resolve a tool's display label + icon: a caller override wins on the label,
93
- * then the built-in map, then a prettified fallback. Exported so other agent
94
- * surfaces (e.g. `AgentProgress`) render the same labels. */
102
+ * then the active locale's `agentRun.tools`, then a prettified fallback.
103
+ * Exported so other agent surfaces (e.g. `AgentProgress`) render the same
104
+ * labels; `toolLabels` is optional so an existing external caller keeps
105
+ * working (it just falls back to the prettified name). */
95
106
  export function resolveToolMeta(
96
107
  call: AgentToolCall,
97
108
  labelForCall?: (call: AgentToolCall) => string | undefined,
109
+ toolLabels?: Record<string, string>,
98
110
  ): { label: string; icon: IconName } {
99
- const def =
100
- TOOL_META[call.toolName] ??
101
- (call.toolName.startsWith("generate_") && call.toolName.endsWith("_from_template")
102
- ? { label: "Generating document", icon: "file-text" as IconName }
103
- : undefined);
111
+ const generated =
112
+ call.toolName.startsWith("generate_") && call.toolName.endsWith("_from_template");
113
+ const label =
114
+ toolLabels?.[call.toolName] ??
115
+ (generated ? toolLabels?.generate_docx_from_template : undefined);
104
116
  return {
105
- label: labelForCall?.(call) ?? def?.label ?? prettifyToolName(call.toolName),
106
- icon: def?.icon ?? "list",
117
+ label: labelForCall?.(call) ?? label ?? prettifyToolName(call.toolName),
118
+ icon: TOOL_ICONS[call.toolName] ?? (generated ? "file-text" : "list"),
107
119
  };
108
120
  }
109
121
 
110
122
  /** Resolve a tool step's display label from its call. */
111
- function stepLabel(s: AgentStep, labelForCall?: (call: AgentToolCall) => string | undefined): string {
112
- return resolveToolMeta({ toolName: s.toolName, input: s.input, state: s.status }, labelForCall).label;
123
+ function stepLabel(
124
+ s: AgentStep,
125
+ labelForCall?: (call: AgentToolCall) => string | undefined,
126
+ toolLabels?: Record<string, string>,
127
+ ): string {
128
+ return resolveToolMeta(
129
+ { toolName: s.toolName, input: s.input, state: s.status },
130
+ labelForCall,
131
+ toolLabels,
132
+ ).label;
113
133
  }
114
134
 
115
135
  const INK = colors.zinc[700];
@@ -124,7 +144,7 @@ const INK = colors.zinc[700];
124
144
  * Pair with `Composer` + `ChangeReview`.
125
145
  */
126
146
  export function AgentRun(props: AgentRunProps) {
127
- const { parts, labelForCall, renderToolOutput, onRetry, stepsLabel = (n) => `${n} steps`, accessibilityLabel } = props;
147
+ const { parts, labelForCall, renderToolOutput, onRetry, stepsLabel, accessibilityLabel } = props;
128
148
  const locale = useLoticsLocale();
129
149
  const segments = toSegments(parts);
130
150
  const state = props.state ?? (anyRunning(segments) ? "streaming" : "done");
@@ -163,7 +183,7 @@ export function AgentRun(props: AgentRunProps) {
163
183
  onToggle={() => toggle(seg.id)}
164
184
  labelForCall={labelForCall}
165
185
  renderToolOutput={renderToolOutput}
166
- stepsLabel={stepsLabel}
186
+ stepsLabel={stepsLabel ?? locale.agentRun.steps}
167
187
  />
168
188
  );
169
189
  })}
@@ -369,7 +389,7 @@ function ToolGroup(props: {
369
389
  }) {
370
390
  const { steps, active, expanded, onToggle, labelForCall, renderToolOutput, stepsLabel } = props;
371
391
  const locale = useLoticsLocale();
372
- const resolve = (s: AgentStep) => stepLabel(s, labelForCall);
392
+ const resolve = (s: AgentStep) => stepLabel(s, labelForCall, locale.agentRun.tools);
373
393
 
374
394
  // ACTIVE — one pulsing row whose label swaps in place as each call fires (the
375
395
  // label is keyed by the current step's id, so a new call rises + fades into the
@@ -0,0 +1,134 @@
1
+ import { useCallback, useMemo, type ReactNode } from "react";
2
+ import { View } from "react-native";
3
+ import { AgentRun, type AgentRunProps } from "./agent_run";
4
+ import type { AgentUIPart } from "./agent_transform";
5
+ import { ClarifyWizard, ClarifyWizardActions, ClarifyWizardScope, type ClarifyWizardAnswer } from "./clarify_wizard";
6
+ import { DialogScrollArea } from "./dialog";
7
+ import { FollowScroll } from "./follow_scroll";
8
+ import { useScreenSize } from "@lotics/ui/use_screen_size";
9
+
10
+ /**
11
+ * What this pair needs from a run — structurally the shape `useAgentRun()`
12
+ * returns, declared here rather than imported so `@lotics/ui` keeps its one-way
13
+ * boundary and never depends on `@lotics/app-sdk`. Nothing enforces the match at
14
+ * build time; the app is where the two meet, so its typecheck is the detector.
15
+ */
16
+ /** One option the agent offered. The answer's `value` IS the label — that is the
17
+ * `ask_user_choice` wire shape, not a convenience. */
18
+ export interface AgentRunOption {
19
+ label: string;
20
+ description?: string;
21
+ }
22
+
23
+ /** One question the agent asked, exactly as `pendingChoice` carries it. Named so
24
+ * a consumer can DECLARE one — a template or test builds these directly, and
25
+ * without a name it would borrow `ClarifyWizardQuestion` and map between two
26
+ * shapes that a real run never maps between. */
27
+ export interface AgentRunQuestion {
28
+ question: string;
29
+ options: AgentRunOption[];
30
+ allow_custom?: boolean;
31
+ }
32
+
33
+ export interface AgentRunLike {
34
+ status: "idle" | "streaming" | "awaiting_input" | "completed" | "error";
35
+ parts: readonly AgentUIPart[];
36
+ /** Non-null exactly while parked. */
37
+ pendingChoice: { questions: AgentRunQuestion[] } | null;
38
+ answerChoice: (answers: { value: string; custom: boolean }[]) => Promise<unknown>;
39
+ error: string | null;
40
+ }
41
+
42
+ export function AgentRunScope({ children }: { children: ReactNode }) {
43
+ return <ClarifyWizardScope>{children}</ClarifyWizardScope>;
44
+ }
45
+
46
+ export interface AgentRunPaneProps {
47
+ run: AgentRunLike;
48
+ labelForCall?: AgentRunProps["labelForCall"];
49
+ renderToolOutput?: AgentRunProps["renderToolOutput"];
50
+ /** Abandon a parked question — the run is left for the operator to retry. */
51
+ onCancel: () => void;
52
+ }
53
+
54
+ /**
55
+ * The run's content: the streaming transcript, or — once the agent asks — the
56
+ * question IN ITS PLACE.
57
+ *
58
+ * The question REPLACES the feed rather than stacking under it. The run is
59
+ * blocked on the answer, so the question is the only thing to act on; and
60
+ * stacking is what broke it in production — the wizard sat below the scroller in
61
+ * a container that could not scroll, so a multi-question ask ran past the
62
+ * dialog's height and clipped its own Submit. Every app rebuilt this arrangement
63
+ * by hand and it only had to be got wrong once.
64
+ */
65
+ export function AgentRunPane(props: AgentRunPaneProps) {
66
+ const { run, labelForCall, renderToolOutput, onCancel } = props;
67
+ const { small } = useScreenSize();
68
+ const pending = run.pendingChoice;
69
+
70
+ // Mapped once per question set, not per render: the wizard keys its per-step
71
+ // draft off identity, and a fresh array every render is a new identity.
72
+ const questions = useMemo(
73
+ () =>
74
+ (pending?.questions ?? []).map((q) => ({
75
+ question: q.question,
76
+ answers: q.options.map((o) => ({ value: o.label, label: o.label, description: o.description ?? "" })),
77
+ allowCustom: q.allow_custom === true,
78
+ })),
79
+ [pending],
80
+ );
81
+ const submit = useCallback(
82
+ (answers: ClarifyWizardAnswer[]) => {
83
+ void run.answerChoice(answers.map((a) => ({ value: a.value, custom: a.custom })));
84
+ },
85
+ [run],
86
+ );
87
+
88
+ if (pending) {
89
+ return (
90
+ <DialogScrollArea>
91
+ <View style={{ paddingVertical: 4 }}>
92
+ <ClarifyWizard questions={questions} onSubmit={submit} onCancel={onCancel} />
93
+ </View>
94
+ </DialogScrollArea>
95
+ );
96
+ }
97
+
98
+ // FollowScroll, not DialogScrollArea: the transcript grows from the bottom and
99
+ // the newest step has to stay in view. Swapping the scroller on the park is
100
+ // deliberate — remounting opens the question at the top rather than wherever
101
+ // the feed happened to be scrolled to.
102
+ return (
103
+ <FollowScroll contentContainerStyle={{ paddingBottom: 24, paddingHorizontal: small ? 16 : 24 }}>
104
+ {/* No empty-state slot on purpose. `AgentRun` renders its own breathing
105
+ "Starting…" row while streaming with zero parts, localized through the
106
+ `agentRun` locale slice — and ai_patterns states the law outright:
107
+ never hand-roll a placeholder in front of the feed. A slot here would
108
+ invite exactly that, and every app would localize it again. */}
109
+ <AgentRun
110
+ parts={run.parts}
111
+ state={run.status === "error" ? "error" : run.status === "streaming" ? "streaming" : "done"}
112
+ error={run.error ?? undefined}
113
+ labelForCall={labelForCall}
114
+ renderToolOutput={renderToolOutput}
115
+ />
116
+ </FollowScroll>
117
+ );
118
+ }
119
+
120
+ /**
121
+ * The run's action bar, for a `DialogFooter`. Renders the wizard's
122
+ * Cancel/Back/Next/Submit while a question is up and NOTHING otherwise, so the
123
+ * host can mount it unconditionally and let the run decide.
124
+ *
125
+ * Pinned outside the scroller is the point: no length of question can push
126
+ * Submit out of reach.
127
+ */
128
+ export function AgentRunActions({ run }: { run: AgentRunLike }) {
129
+ // Reads the SAME run the pane reads, rather than state the pane publishes: two
130
+ // components deriving from one source cannot disagree, and nothing has to set
131
+ // a parent's state during a child's render to keep them in step.
132
+ if (run.pendingChoice == null) return null;
133
+ return <ClarifyWizardActions />;
134
+ }
package/src/locale.tsx CHANGED
@@ -132,9 +132,27 @@ export interface LoticsLocale {
132
132
  /** `AgentRun`: the reasoning disclosure's label (settled / streaming), the
133
133
  * auto-built tool peek's Input / Error / Output panel titles, the `awaiting`
134
134
  * annotation on a call parked on a human decision, and the terminal error's
135
- * `retry` action. (Tool-step labels and the "{n} steps" suffix stay
136
- * prop-localized `labelForCall` / `stepsLabel`.) */
137
- agentRun: { starting: string; thinking: string; thinkingStreaming: string; input: string; error: string; output: string; awaiting: string; retry: string };
135
+ * `retry` action, the per-tool labels (`tools`) and the "{n} steps" suffix
136
+ * (`steps`). `labelForCall` / `stepsLabel` remain as per-call-site
137
+ * OVERRIDES, not as the only way to get a translation. */
138
+ /** `AgentRun` / `AgentProgress` chrome, plus `tools` — the display label per
139
+ * PLATFORM tool name. The tool set is bounded and kit-known, so localizing it
140
+ * here means every app inherits it; leaving it to each call site's
141
+ * `labelForCall` made a Vietnamese surface render Vietnamese chrome around
142
+ * English tool rows. A name absent from the map still falls back to its
143
+ * prettified form, and `labelForCall` still overrides per call. */
144
+ agentRun: {
145
+ starting: string;
146
+ thinking: string;
147
+ thinkingStreaming: string;
148
+ input: string;
149
+ error: string;
150
+ output: string;
151
+ awaiting: string;
152
+ retry: string;
153
+ tools: Record<string, string>;
154
+ steps: (n: number) => string;
155
+ };
138
156
  /** `ApprovalPrompt`: the default prompt line (overridable per instance) and
139
157
  * the Approve / Deny button labels — the surface that ANSWERS `AgentRun`'s
140
158
  * read-only `awaiting` row (approve/deny = the ai-sdk approval vocabulary). */
@@ -237,7 +255,35 @@ export const en: LoticsLocale = {
237
255
  matrix: { total: "Total", less: "Less", more: "More" },
238
256
  scrollToBottom: { tooltip: "Scroll to bottom" },
239
257
  textInputField: { clear: "Clear" },
240
- agentRun: { starting: "Starting…", thinking: "Thinking", thinkingStreaming: "Thinking…", input: "Input", error: "Error", output: "Output", awaiting: "Awaiting", retry: "Retry" },
258
+ agentRun: {
259
+ starting: "Starting…",
260
+ thinking: "Thinking",
261
+ thinkingStreaming: "Thinking…",
262
+ input: "Input",
263
+ error: "Error",
264
+ output: "Output",
265
+ awaiting: "Awaiting",
266
+ retry: "Retry",
267
+ tools: {
268
+ query_records: "Searching records",
269
+ get_record: "Reading a record",
270
+ get_records: "Reading records",
271
+ create_records: "Creating records",
272
+ update_records: "Updating records",
273
+ delete_records: "Removing records",
274
+ run_app_query: "Reading data",
275
+ run_app_workflow: "Running an action",
276
+ grep_knowledge: "Searching reference material",
277
+ read_knowledge: "Reading reference material",
278
+ list_knowledge: "Listing reference material",
279
+ view_files: "Reading files",
280
+ ask_user_choice: "Asking a question",
281
+ generate_pdf_from_template: "Generating PDF",
282
+ generate_excel_from_template: "Generating spreadsheet",
283
+ generate_docx_from_template: "Generating document",
284
+ },
285
+ steps: (n) => `${n} steps`,
286
+ },
241
287
  approvalPrompt: { message: "The assistant wants to perform an action that needs your approval.", approve: "Approve", deny: "Deny" },
242
288
  messageActions: { copy: "Copy", copied: "Copied", regenerate: "Regenerate", edit: "Edit", previousVersion: "Previous version", nextVersion: "Next version" },
243
289
  };
@@ -335,7 +381,35 @@ export const vi: LoticsLocale = {
335
381
  matrix: { total: "Tổng", less: "Ít", more: "Nhiều" },
336
382
  scrollToBottom: { tooltip: "Cuộn xuống cuối" },
337
383
  textInputField: { clear: "Xóa" },
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" },
384
+ agentRun: {
385
+ starting: "Đang bắt đầu…",
386
+ thinking: "Suy nghĩ",
387
+ thinkingStreaming: "Đang suy nghĩ…",
388
+ input: "Đầu vào",
389
+ error: "Lỗi",
390
+ output: "Kết quả",
391
+ awaiting: "Chờ duyệt",
392
+ retry: "Thử lại",
393
+ tools: {
394
+ query_records: "Đang tìm dữ liệu",
395
+ get_record: "Đang đọc một bản ghi",
396
+ get_records: "Đang đọc dữ liệu",
397
+ create_records: "Đang tạo dữ liệu",
398
+ update_records: "Đang cập nhật dữ liệu",
399
+ delete_records: "Đang xoá dữ liệu",
400
+ run_app_query: "Đang đọc dữ liệu",
401
+ run_app_workflow: "Đang chạy tác vụ",
402
+ grep_knowledge: "Đang tra tài liệu",
403
+ read_knowledge: "Đang đọc tài liệu",
404
+ list_knowledge: "Đang liệt kê tài liệu",
405
+ view_files: "Đang đọc tệp",
406
+ ask_user_choice: "Đang hỏi thêm",
407
+ generate_pdf_from_template: "Đang tạo PDF",
408
+ generate_excel_from_template: "Đang tạo bảng tính",
409
+ generate_docx_from_template: "Đang tạo văn bản",
410
+ },
411
+ steps: (n) => `${n} bước`,
412
+ },
339
413
  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
414
  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
415
  };