@lotics/ui 20.1.0 → 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 +1 -1
- package/docs/ai_patterns.md +38 -0
- package/docs/catalog.md +12 -0
- package/examples/tpl_item_list.tsx +55 -50
- package/package.json +2 -1
- package/src/agent_run_pane.tsx +134 -0
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
|
|
package/docs/ai_patterns.md
CHANGED
|
@@ -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
|
@@ -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 {
|
|
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 →
|
|
1056
|
-
//
|
|
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()
|
|
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
|
-
|
|
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
|
-
|
|
1092
|
-
{
|
|
1093
|
-
{
|
|
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
|
-
|
|
1099
|
-
|
|
1100
|
-
{
|
|
1101
|
-
{
|
|
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
|
-
<
|
|
1273
|
+
<AgentRunScope>
|
|
1248
1274
|
{/* the file/stream phases (intake/analyze/clarify/running/done) run
|
|
1249
|
-
WIDE — room for the thumbnail hero + the
|
|
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
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
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
|
-
//
|
|
1329
|
-
//
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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.
|
|
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",
|
|
@@ -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
|
+
}
|