@lotics/ui 13.10.0 → 14.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.
@@ -93,26 +93,27 @@ transition — never leave a live button waiting on a network round-trip:
93
93
  ## Show the work — `AgentRun`
94
94
 
95
95
  `AgentRun` (`@lotics/ui/agent_run`) is a live feed of the agent's work as a TIMELINE. The prop is
96
- an ORDERED `items` array (`AgentRunItem`):
96
+ the agent message's **ai-sdk `parts`** (`UIMessagePart[]`) — the same wire shape chat and app
97
+ agents emit; `@lotics/ui` owns the transform from parts to the render timeline, so there is no
98
+ bespoke transcript type. It renders three part kinds:
97
99
 
98
- | Item | Shape | Renders as |
99
- |---|---|---|
100
- | `{ type: "text", id, text }` | Answer prose | `Markdown`, a trailing caret on the last segment while streaming |
101
- | `{ type: "reasoning", id, text }` | Thinking | COLLAPSED a muted "Thinking" row; press to reveal the Markdown |
102
- | `{ type: "step", id, label, status, kind?, input?, output?, errorText?, detail?, peek? }` | A tool/step call | An activity row with a status dot |
103
-
104
- Items appear in the order they happened — a real run is think → text → a burst of calls → more
105
- text, NOT all text on top of a flat step list. Transparent work, NEVER a bare spinner.
106
-
107
- **Progressive disclosure.** The feed shows the label + state; the detail is revealed on demand.
108
- A `step`'s `input`/`output` are hidden in the row but open in a press-to-reveal **peek**
109
- (auto-built Input / Output `JsonPanel`s). On `status: "error"` the row's dot goes amber, the
110
- `detail` line reads in the danger ink, and `errorText` shows in the peek's Error panel. Pass an
111
- explicit `peek` node to render that reveal yourself (it overrides the auto panels). `detail` is
112
- OPTIONAL a short human summary (a count), never invented prose or raw I/O (that's
113
- `input`/`output`).
114
-
115
- **Activity grouping.** Consecutive `step` items fold into ONE group. While the agent is mid-tools
100
+ | Part | Renders as |
101
+ |---|---|
102
+ | `text` | Answer prose (`Markdown`), grows as it streams |
103
+ | `reasoning` | Thinking COLLAPSED, a muted "Thinking" row; press to reveal the Markdown |
104
+ | a tool part `dynamic-tool` (app agents) or `tool-<name>` (chat) | An activity row with a status dot |
105
+
106
+ Parts appear in the order they happened — a real run is think → text → a burst of calls → more
107
+ text, NOT all text on top of a flat step list. Content parts with no feed representation
108
+ (`source-*`, `file`, `data-*`, `step-start`, `custom`) are skipped. Transparent work, NEVER a bare spinner.
109
+
110
+ **Progressive disclosure.** The feed shows the label + state; the detail is revealed on demand. A
111
+ tool part's `input`/`output` are hidden in the row and **expand in place** on press — auto-built
112
+ Input / Output `JsonPanel`s roll out under the row (the same disclosure pattern as "Thinking",
113
+ never a popover). A tool in an `output-error` state flows like any other call the dot goes
114
+ amber and its `errorText` shows in the expanded Error panel.
115
+
116
+ **Activity grouping.** Consecutive tool parts fold into ONE group. While the agent is mid-tools
116
117
  the tail group is a SINGLE pulsing row whose label swaps in place as each call fires (no growing
117
118
  stack of dots); once prose resumes the group settles: a single call becomes one done row, and a
118
119
  multi-call group becomes a persistent "{final action} · {n} steps" HEADER (a `complete` terminal
@@ -120,17 +121,28 @@ dot — distinct from the filled `done` step dots; amber `warning` if any call e
120
121
  PUT and rolls the calls out BELOW it on press. The run ALWAYS ends on the agent's text — there is
121
122
  no global terminal node.
122
123
 
123
- **Tool labels.** A `step` with `kind: "tool"` carries the RAW tool name (`update_records`),
124
- resolved via a built-in map of the platform record/document tools + an optional `labelForTool`
125
- override (localize THERE — the kit stays English); unknown names fall back to a prettified form.
126
- `stepsLabel` localizes the "{n} steps" suffix. `state` (`"streaming" | "done" | "error"`)
127
- defaults to `streaming` while any step is running, else `done`.
128
-
129
- Fed natively by `@lotics/app-sdk`'s `useAgentRun().items` (reasoning + per-tool I/O + state come
130
- for free — no hand-assembly): `<AgentRun items={run.items} state={…} />` — see
131
- [the SDK doc](../../app-sdk/docs/ai.md) for the hook.
132
-
133
- **Limitation:** the collapsed reasoning row's "Thinking" label and the auto peek's
124
+ **Tool labels.** A tool part carries the RAW tool name (`update_records` from `tool-update_records`,
125
+ or a `dynamic-tool`'s `toolName`), resolved via a built-in map of the platform record/document
126
+ tools + an optional `labelForTool` override (localize THERE — the kit stays English); unknown
127
+ names fall back to a prettified form. `stepsLabel` localizes the "{n} steps" suffix. The tool
128
+ `state` collapses ai's lifecycle to running / done / error (an approval-pending state reads as
129
+ running — a read-only feed has no approval control). The whole-run `state` prop
130
+ (`"streaming" | "done" | "error"`) defaults to `streaming` while any tool is running, else `done`.
131
+
132
+ **Errors two kinds, both surfaced, neither shouting.** An INTERMEDIARY tool failure rides in
133
+ that tool's `output-error` part and flows like a normal call — the amber dot marks it (a settled
134
+ group's header goes amber too), the reason waits in the expanded Error panel; real runs retry and
135
+ move on, so the feed doesn't dramatize it. A BREAKING error that terminates the whole run lives
136
+ OUTSIDE `parts` (chat persists it in the message's `errors`; `useAgentRun().error` carries it) —
137
+ pass it as the `error` prop and it renders as a terminal danger row under the transcript.
138
+
139
+ Fed natively by `@lotics/app-sdk`'s `useAgentRun().parts` (reasoning + per-tool I/O + state come
140
+ for free — no hand-assembly): `<AgentRun parts={run.parts} state={…} error={run.error} />` — see
141
+ [the SDK doc](../../app-sdk/docs/ai.md) for the hook. Requires `ai` as an optional (type-only) peer.
142
+ **Reasoning only shows when the model produces it** — the declared model must support thinking
143
+ (Sonnet 4.6 / Opus 4.8, not Haiku); the renderer shows every reasoning part it's handed.
144
+
145
+ **Limitation:** the collapsed reasoning row's "Thinking" label and the expanded detail's
134
146
  "Input"/"Output"/"Error" panel titles are currently English-only (not on the locale provider and
135
147
  not prop-overridable); `labelForTool`/`stepsLabel` localize everything else in the feed.
136
148
 
package/docs/catalog.md CHANGED
@@ -962,11 +962,20 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
962
962
  - **`composer`** — `Composer`: the adaptive command/chat composer — a compact pill that
963
963
  expands for long text + attachments (`pills` + attachment slots, controlled or
964
964
  uncontrolled `value`, `onSend`/`onStop`, `sendDisabled` override).
965
- - **`agent_run`** — `AgentRun` + `AgentRunStep`/`AgentRunItem` + `resolveToolMeta`: the live
966
- streaming work feed. AI work ONLY deterministic work (template fill, an export) shows a
967
- `loading` state on its trigger and delivers at once; dressing it as an agent run is theater.
968
- - **`agent_progress`** `AgentProgress`: its compact, floating, expandable form a
969
- composer's working state.
965
+ - **`agent_run`** — `AgentRun` + `resolveToolMeta`: the live streaming work feed. Its prop is the
966
+ agent message's ai-sdk `parts` (`UIMessagePart[]`text, reasoning, and tool parts), fed straight
967
+ from `useAgentRun().parts`; renders prose, a collapsed "Thinking" disclosure, and grouped tool
968
+ steps whose I/O expands in place on press (no popover). A per-tool failure shows amber with the
969
+ reason in its expanded Error panel; a run-level
970
+ breaking `error` (outside `parts`) renders as a terminal danger row. Needs `ai` as an optional
971
+ type-only peer. AI work ONLY —
972
+ deterministic work (template fill, an export) shows a `loading` state on its trigger and delivers
973
+ at once; dressing it as an agent run is theater.
974
+ - **`agent_transform`** — internal (no component): the one place that folds an ai-sdk `UIMessage`'s
975
+ `parts` into the render timeline (`AgentUIPart`, `toSegments`, `lastRunningStep`), shared by
976
+ `AgentRun` and `AgentProgress`. The sole file permitted to `import type` from `ai`.
977
+ - **`agent_progress`** — `AgentProgress`: `AgentRun`'s compact, floating, expandable form (same
978
+ `parts` prop) — a composer's working state.
970
979
  - **`confidence`** — `Confidence` + `ConfidenceLevel` + `levelFromScore`: calibrated
971
980
  high/med/low; localized via the provider.
972
981
  - **`change_review`** — the COMPOUND review family — frame: `ChangeReview` provider/stack ·
@@ -1031,4 +1040,5 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
1031
1040
  a programmatic focus restore inside the suppression window doesn't re-open the editor it
1032
1041
  just closed.
1033
1042
  - **`json_panel`** — `JsonPanel` (`{title, value}`) + `stringifyData`: a labeled monospace
1034
- panel for raw/JSON payloads (debug & developer surfaces).
1043
+ panel for raw/JSON payloads (debug & developer surfaces). Height-capped (240) and scrolls
1044
+ inside, so a huge dump (a streamed tool input) can never blow the surface open.
@@ -10,7 +10,9 @@ import { InlineTextInput } from "@lotics/ui/inline_text_input";
10
10
  import { AgentProgress } from "@lotics/ui/agent_progress";
11
11
  import { Composer } from "@lotics/ui/composer";
12
12
  import { FileThumbnail, type DisplayFile } from "@lotics/ui/file_thumbnail";
13
- import { type AgentRunStep, type AgentRunItem } from "@lotics/ui/agent_run";
13
+ import type { UIMessagePart, UIDataTypes, UITools } from "ai";
14
+
15
+ type Part = UIMessagePart<UIDataTypes, UITools>;
14
16
 
15
17
  // A real app fills the attachment from `useFileUpload` (the uploaded file's URL);
16
18
  // this mock uses an inline placeholder so the example needs no network image.
@@ -34,20 +36,20 @@ const MOCK_PHOTO: DisplayFile = {
34
36
  // carries the single Download. All mock — the recipe, not a CAD engine.
35
37
  // ─────────────────────────────────────────────────────────────────────────────
36
38
 
37
- type ScriptStep = Omit<AgentRunStep, "status">;
39
+ type ScriptStep = { id: string; label: string };
38
40
  type Dims = { L: number; W: number; H: number };
39
41
 
40
42
  const RECOGNIZE: ScriptStep[] = [
41
- { id: "r1", label: "Reading the photo", detail: "A single brown carton on a white sweep, card left for scale." },
42
- { id: "r2", label: "reference_dielines.search", kind: "tool" },
43
- { id: "r3", label: "Identifying the box style", detail: "Four flaps meet at the centre seam → Regular Slotted Container, FEFCO 0201." },
44
- { id: "r4", label: "Estimating dimensions", detail: "Scaling against the reference card — about 320 × 230 × 150 mm." },
45
- { id: "r5", label: "Drafting the dieline", kind: "tool" },
43
+ { id: "r1", label: "Reading the photo" },
44
+ { id: "r2", label: "reference_dielines.search" },
45
+ { id: "r3", label: "Identifying the box style" },
46
+ { id: "r4", label: "Estimating dimensions" },
47
+ { id: "r5", label: "Drafting the dieline" },
46
48
  ];
47
49
 
48
- const editScript = (prompt: string): ScriptStep[] => [
49
- { id: "e1", label: "Reading the request", detail: prompt },
50
- { id: "e2", label: "Recomputing the dieline", kind: "tool" },
50
+ const EDIT_SCRIPT: ScriptStep[] = [
51
+ { id: "e1", label: "Reading the request" },
52
+ { id: "e2", label: "Recomputing the dieline" },
51
53
  ];
52
54
 
53
55
  const ZOOM_MIN = 0.4;
@@ -105,12 +107,13 @@ export function TplDieline() {
105
107
  return () => clearTimeout(t);
106
108
  }, [run, revealed]);
107
109
 
108
- const liveItems: AgentRunItem[] = run
109
- ? run.script.slice(0, revealed).map((s, i) => ({
110
- type: "step",
111
- ...s,
112
- status: revealed < run.script.length && i === revealed - 1 ? "running" : "done",
113
- }))
110
+ const liveItems: Part[] = run
111
+ ? run.script.slice(0, revealed).map((s, i): Part => {
112
+ const running = revealed < run.script.length && i === revealed - 1;
113
+ return running
114
+ ? { type: "dynamic-tool", toolName: s.label, toolCallId: s.id, state: "input-available", input: undefined }
115
+ : { type: "dynamic-tool", toolName: s.label, toolCallId: s.id, state: "output-available", input: undefined, output: undefined };
116
+ })
114
117
  : [];
115
118
 
116
119
  const startRecognize = () => {
@@ -135,7 +138,7 @@ export function TplDieline() {
135
138
  setPhase("working");
136
139
  setRevealed(0);
137
140
  setRun({
138
- script: editScript(text),
141
+ script: EDIT_SCRIPT,
139
142
  onDone: () => {
140
143
  const m = /(\d+)\s*mm/i.exec(text);
141
144
  const delta = m ? Number(m[1]) : 5;
@@ -209,7 +212,7 @@ export function TplDieline() {
209
212
  <View style={{ position: "absolute", left: 0, right: 0, bottom: 24, alignItems: "center", paddingHorizontal: 16, pointerEvents: "none" }}>
210
213
  <View style={{ width: "100%", maxWidth: 620, pointerEvents: "auto" }}>
211
214
  {phase === "working" ? (
212
- <AgentProgress items={liveItems} state="streaming" />
215
+ <AgentProgress parts={liveItems} state="streaming" />
213
216
  ) : (
214
217
  <Composer
215
218
  value={prompt}
@@ -22,11 +22,14 @@ import { useSelection } from "@lotics/ui/use_selection";
22
22
  import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
23
23
  import { Dialog, DialogHeader, DialogHeaderTitle, DialogScrollArea, DialogFooter } from "@lotics/ui/dialog";
24
24
  import { CardSelectItem } from "@lotics/ui/card_select_item";
25
- import { AgentRun, type AgentRunItem, type AgentRunStep } from "@lotics/ui/agent_run";
25
+ import { AgentRun } from "@lotics/ui/agent_run";
26
26
  import { type SourceRef } from "@lotics/ui/sources";
27
27
  import { ChangeValueInput, Change, ChangeField, ChangeFields, ChangeReasoning, ChangeRecord, ChangeReview, ChangeReviewActions, ChangeReviewHeader, type ChangeStatus } from "@lotics/ui/change_review";
28
28
 
29
29
  import { CompletionState } from "@lotics/ui/completion_state";
30
+ import type { UIMessagePart, UIDataTypes, UITools } from "ai";
31
+
32
+ type Part = UIMessagePart<UIDataTypes, UITools>;
30
33
 
31
34
  // ─────────────────────────────────────────────────────────────────────────────
32
35
  // Template · Document desk — THE worked example for everything document-driven
@@ -50,7 +53,7 @@ import { CompletionState } from "@lotics/ui/completion_state";
50
53
  // split) and drops the toolbar button.
51
54
  // ─────────────────────────────────────────────────────────────────────────────
52
55
 
53
- type ScriptStep = Omit<AgentRunStep, "status">;
56
+ type ScriptStep = { id: string; label: string; detail?: string; kind?: "tool" };
54
57
  type Task = "extract" | "check";
55
58
  type Phase = "fork" | "running" | "review" | "done";
56
59
 
@@ -276,14 +279,16 @@ export function TplDocuments() {
276
279
  return () => clearTimeout(t);
277
280
  }, [phase, revealed, script.length]);
278
281
 
279
- const runItems: AgentRunItem[] = [
280
- { type: "text", id: "in", text: (task === "check" ? "Comparing the documents against the record and each other." + (brief.trim() ? ` Also: ${brief.trim()}` : "") : "Reading the documents and comparing them with the record.") },
281
- ...script.slice(0, Math.min(revealed + 1, script.length)).map((s, i): AgentRunItem => ({
282
- type: "step", ...s, detail: i < revealed ? s.detail : undefined, status: i < revealed ? "done" : "running",
283
- })),
282
+ const runItems: Part[] = [
283
+ { type: "text", text: (task === "check" ? "Comparing the documents against the record and each other." + (brief.trim() ? ` Also: ${brief.trim()}` : "") : "Reading the documents and comparing them with the record.") },
284
+ ...script.slice(0, Math.min(revealed + 1, script.length)).map((s, i): Part =>
285
+ i < revealed
286
+ ? { type: "dynamic-tool", toolName: s.label, toolCallId: s.id, state: "output-available", input: undefined, output: undefined }
287
+ : { type: "dynamic-tool", toolName: s.label, toolCallId: s.id, state: "input-available", input: undefined },
288
+ ),
284
289
  ];
285
290
  if (revealed >= script.length) {
286
- runItems.push({ type: "text", id: "out", text: task === "check" ? "Checked 18 fields — 2 disagree, 1 worth noting. The findings are below." : "3 fields already match; 3 need a decision, and the invoice carries 2 lines the order doesn't have yet." });
291
+ runItems.push({ type: "text", text: task === "check" ? "Checked 18 fields — 2 disagree, 1 worth noting. The findings are below." : "3 fields already match; 3 need a decision, and the invoice carries 2 lines the order doesn't have yet." });
287
292
  }
288
293
 
289
294
  // Apply counts what actually commits: the kept fields.
@@ -498,7 +503,7 @@ export function TplDocuments() {
498
503
  </View>
499
504
  ) : null}
500
505
 
501
- {phase === "running" ? <AgentRun items={runItems} state={revealed >= script.length ? "done" : "streaming"} /> : null}
506
+ {phase === "running" ? <AgentRun parts={runItems} state={revealed >= script.length ? "done" : "streaming"} /> : null}
502
507
 
503
508
  {phase === "review" && task === "extract" ? (
504
509
  <View style={{ gap: 16 }}>
@@ -695,9 +700,11 @@ export function TplDocuments() {
695
700
  </View>
696
701
  ) : genRunning ? (
697
702
  <AgentRun
698
- items={genSteps.slice(0, Math.min(genRevealed + 1, genSteps.length)).map((st, i) => ({
699
- type: "step", id: st.id, label: st.label, status: i < genRevealed ? "done" : "running",
700
- }))}
703
+ parts={genSteps.slice(0, Math.min(genRevealed + 1, genSteps.length)).map((st, i): Part =>
704
+ i < genRevealed
705
+ ? { type: "dynamic-tool", toolName: st.label, toolCallId: st.id, state: "output-available", input: undefined, output: undefined }
706
+ : { type: "dynamic-tool", toolName: st.label, toolCallId: st.id, state: "input-available", input: undefined },
707
+ )}
701
708
  state={genRevealed >= genSteps.length ? "done" : "streaming"}
702
709
  />
703
710
  ) : !genDone ? (
@@ -6,10 +6,13 @@ import { Divider } from "@lotics/ui/divider";
6
6
  import { Callout, CalloutText, CalloutTitle } from "@lotics/ui/callout";
7
7
  import { PressableHighlight } from "@lotics/ui/pressable_highlight";
8
8
  import { Composer } from "@lotics/ui/composer";
9
- import { AgentRun, type AgentRunStep, type AgentRunItem } from "@lotics/ui/agent_run";
9
+ import { AgentRun } from "@lotics/ui/agent_run";
10
10
  import { Confidence, type ConfidenceLevel } from "@lotics/ui/confidence";
11
11
  import { Badge } from "@lotics/ui/badge";
12
12
  import { Sources, type SourceRef } from "@lotics/ui/sources";
13
+ import type { UIMessagePart, UIDataTypes, UITools } from "ai";
14
+
15
+ type Part = UIMessagePart<UIDataTypes, UITools>;
13
16
 
14
17
  // ─────────────────────────────────────────────────────────────────────────────
15
18
  // Template · Answer desk — describe the goods on the LEFT, the agent RANKS the
@@ -134,13 +137,13 @@ const CANDIDATES: Candidate[] = [
134
137
  },
135
138
  ];
136
139
 
137
- type ScriptStep = Omit<AgentRunStep, "status">;
140
+ type ScriptStep = { id: string; toolName: string; input?: unknown; output?: unknown };
138
141
  const SCRIPT: ScriptStep[] = [
139
- { id: "r1", label: "Reading the description", detail: "Form, material, processing and presentation extracted." },
140
- { id: "r2", label: "tariff_schedule.search", kind: "tool" },
141
- { id: "r3", label: "Narrowing the heading", detail: "Matching the goods against chapter notes and exclusions." },
142
- { id: "r4", label: "duty_rates.read", kind: "tool" },
143
- { id: "r5", label: "Checking import policies", detail: "Permits, certificates and trade-remedy orders for the line." },
142
+ { id: "r1", toolName: "Reading the description", input: undefined },
143
+ { id: "r2", toolName: "tariff_schedule.search" },
144
+ { id: "r3", toolName: "Narrowing the heading", input: undefined },
145
+ { id: "r4", toolName: "duty_rates.read" },
146
+ { id: "r5", toolName: "Checking import policies", input: undefined },
144
147
  ];
145
148
 
146
149
  export function TplLookup() {
@@ -182,11 +185,13 @@ export function TplLookup() {
182
185
  };
183
186
 
184
187
  const streaming = phase === "running" && revealed < SCRIPT.length;
185
- const items: AgentRunItem[] = SCRIPT.slice(0, revealed).map((s, i) => ({
186
- type: "step",
187
- ...s,
188
- status: streaming && i === revealed - 1 ? "running" : "done",
189
- }));
188
+ const items: Part[] = SCRIPT.slice(0, revealed).map((s, i): Part => {
189
+ const running = streaming && i === revealed - 1;
190
+ if (running) {
191
+ return { type: "dynamic-tool", toolName: s.toolName, toolCallId: s.id, state: "input-available", input: s.input };
192
+ }
193
+ return { type: "dynamic-tool", toolName: s.toolName, toolCallId: s.id, state: "output-available", input: s.input, output: s.output };
194
+ });
190
195
 
191
196
  return (
192
197
  <View style={{ flex: 1, flexDirection: "row", backgroundColor: colors.white }}>
@@ -200,7 +205,7 @@ export function TplLookup() {
200
205
 
201
206
  <ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: 16, gap: 16 }}>
202
207
  {/* current run */}
203
- <AgentRun items={items} state={streaming ? "streaming" : "done"} />
208
+ <AgentRun parts={items} state={streaming ? "streaming" : "done"} />
204
209
 
205
210
  {/* Matches surface only once the run settles — the agent's output,
206
211
  never a pre-filled list; they sit at the bottom, under the run. */}
@@ -65,10 +65,13 @@ import { SearchInput } from "@lotics/ui/search_input";
65
65
  import { useSelection } from "@lotics/ui/use_selection";
66
66
  import { FloatingActionBar } from "@lotics/ui/floating_action_bar";
67
67
  import { CardSelectItem } from "@lotics/ui/card_select_item";
68
- import { AgentRun, type AgentRunItem, type AgentRunStep } from "@lotics/ui/agent_run";
68
+ import { AgentRun } from "@lotics/ui/agent_run";
69
69
  import { type SourceRef } from "@lotics/ui/sources";
70
70
  import { ChangeValueInput, Change, ChangeField, ChangeFields, ChangeReasoning, ChangeRecord, ChangeReview, ChangeReviewActions, ChangeReviewHeader, type ChangeStatus } from "@lotics/ui/change_review";
71
71
  import { CompletionState } from "@lotics/ui/completion_state";
72
+ import type { UIMessagePart, UIDataTypes, UITools } from "ai";
73
+
74
+ type Part = UIMessagePart<UIDataTypes, UITools>;
72
75
 
73
76
  // ─────────────────────────────────────────────────────────────────────────────
74
77
  // Template · Record — THE record surface: the surface IS the editor, every
@@ -431,7 +434,7 @@ const CONTENT_MAX = 720;
431
434
  // that forks into extract / cross-check / edit-with-AI. The two templates
432
435
  // share this desk — change it in BOTH (the one divergence: generation lives
433
436
  // in this page's output sections, in tpl_documents' toolbar dialog).
434
- type ScriptStep = Omit<AgentRunStep, "status">;
437
+ type ScriptStep = { id: string; toolName: string; input?: unknown; output?: unknown };
435
438
  type Task = "extract" | "check";
436
439
  type Phase = "fork" | "running" | "review" | "done";
437
440
 
@@ -455,15 +458,15 @@ const DOCS: Doc[] = [
455
458
  ];
456
459
 
457
460
  const EXTRACT_STEPS: ScriptStep[] = [
458
- { id: "e1", label: "Reading the selected documents", detail: "Page by page, tables included" },
459
- { id: "e2", label: "get_record", kind: "tool" },
460
- { id: "e3", label: "Extracting field values", detail: "9 fields · 2 order lines" },
461
- { id: "e4", label: "Comparing against the record", detail: "3 match · 1 new · 1 change · 1 conflict" },
461
+ { id: "e1", toolName: "Reading the selected documents" },
462
+ { id: "e2", toolName: "get_record" },
463
+ { id: "e3", toolName: "Extracting field values" },
464
+ { id: "e4", toolName: "Comparing against the record" },
462
465
  ];
463
466
  const CHECK_STEPS: ScriptStep[] = [
464
- { id: "c1", label: "Reading the selected documents", detail: "Quantities, parties, dates and terms" },
465
- { id: "c2", label: "get_record", kind: "tool" },
466
- { id: "c3", label: "Cross-checking documents and record", detail: "18 fields compared across the sources" },
467
+ { id: "c1", toolName: "Reading the selected documents" },
468
+ { id: "c2", toolName: "get_record" },
469
+ { id: "c3", toolName: "Cross-checking documents and record" },
467
470
  ];
468
471
 
469
472
  // The shapes of an extract decision — an ADD, an UPDATE, a REMOVAL, a source
@@ -959,14 +962,16 @@ export function TplRecord() {
959
962
  return () => clearTimeout(t);
960
963
  }, [phase, revealed, script.length]);
961
964
 
962
- const runItems: AgentRunItem[] = [
963
- { type: "text", id: "in", text: (task === "check" ? "Comparing the documents against the record and each other." + (brief.trim() ? ` Also: ${brief.trim()}` : "") : "Reading the documents and comparing them with the record.") },
964
- ...script.slice(0, Math.min(revealed + 1, script.length)).map((s, i): AgentRunItem => ({
965
- type: "step", ...s, detail: i < revealed ? s.detail : undefined, status: i < revealed ? "done" : "running",
966
- })),
965
+ const runItems: Part[] = [
966
+ { type: "text", text: (task === "check" ? "Comparing the documents against the record and each other." + (brief.trim() ? ` Also: ${brief.trim()}` : "") : "Reading the documents and comparing them with the record.") },
967
+ ...script.slice(0, Math.min(revealed + 1, script.length)).map((s, i): Part => (
968
+ i < revealed
969
+ ? { type: "dynamic-tool", toolName: s.toolName, toolCallId: s.id, state: "output-available", input: s.input, output: s.output }
970
+ : { type: "dynamic-tool", toolName: s.toolName, toolCallId: s.id, state: "input-available", input: s.input }
971
+ )),
967
972
  ];
968
973
  if (revealed >= script.length) {
969
- runItems.push({ type: "text", id: "out", text: task === "check" ? "Checked 18 fields — 2 disagree, 1 worth noting. The findings are below." : "3 fields already match; 3 need a decision, and the invoice carries 2 lines the order doesn't have yet." });
974
+ runItems.push({ type: "text", text: task === "check" ? "Checked 18 fields — 2 disagree, 1 worth noting. The findings are below." : "3 fields already match; 3 need a decision, and the invoice carries 2 lines the order doesn't have yet." });
970
975
  }
971
976
 
972
977
  // Apply counts what actually commits: the kept fields.
@@ -2771,7 +2776,7 @@ export function TplRecord() {
2771
2776
  </View>
2772
2777
  ) : null}
2773
2778
 
2774
- {phase === "running" ? <AgentRun items={runItems} state={revealed >= script.length ? "done" : "streaming"} /> : null}
2779
+ {phase === "running" ? <AgentRun parts={runItems} state={revealed >= script.length ? "done" : "streaming"} /> : null}
2775
2780
 
2776
2781
  {phase === "review" && task === "extract" ? (
2777
2782
  <View style={{ gap: 16 }}>
@@ -27,12 +27,15 @@ import { DataGrid, gridRowStyle, type DataGridColumn, type DataGridGroup } from
27
27
  import { CONTROL_RADIUS } from "@lotics/ui/control_surface";
28
28
  import { cycleSort, sortBy, type SortState } from "@lotics/ui/sort_header";
29
29
  import { Composer } from "@lotics/ui/composer";
30
- import { AgentRun, type AgentRunItem } from "@lotics/ui/agent_run";
30
+ import { AgentRun } from "@lotics/ui/agent_run";
31
31
  import { Change, ChangeReview, ChangeReviewActions, ChangeReviewHeader, ChangeSummary, type ChangeStatus } from "@lotics/ui/change_review";
32
32
  import { Dialog, DialogHeader, DialogHeaderTitle, DialogScrollArea, DialogFooter } from "@lotics/ui/dialog";
33
33
  import { CompletionState } from "@lotics/ui/completion_state";
34
34
  import { Confidence, type ConfidenceLevel } from "@lotics/ui/confidence";
35
35
  import { toISODate } from "@lotics/ui/format_date";
36
+ import type { UIMessagePart, UIDataTypes, UITools } from "ai";
37
+
38
+ type Part = UIMessagePart<UIDataTypes, UITools>;
36
39
 
37
40
  type TagOption = { value: string; label: string };
38
41
 
@@ -253,14 +256,16 @@ export function TplTaskBoard() {
253
256
  return () => clearTimeout(t);
254
257
  }, [phase, revealed]);
255
258
 
256
- const runItems: AgentRunItem[] = [
257
- { type: "text", id: "intro", text: "I read your notes and pulled out the work." },
258
- ...DRAFT_STEPS.slice(0, Math.min(revealed + 1, DRAFT_STEPS.length)).map((s, i): AgentRunItem => ({
259
- type: "step", id: `s${i}`, label: s.label, detail: i < revealed ? s.detail : undefined, status: i < revealed ? "done" : "running",
260
- })),
259
+ const runItems: Part[] = [
260
+ { type: "text", text: "I read your notes and pulled out the work." },
261
+ ...DRAFT_STEPS.slice(0, Math.min(revealed + 1, DRAFT_STEPS.length)).map((s, i): Part =>
262
+ i < revealed
263
+ ? { type: "dynamic-tool", toolName: s.label, toolCallId: `s${i}`, state: "output-available", input: undefined, output: undefined }
264
+ : { type: "dynamic-tool", toolName: s.label, toolCallId: `s${i}`, state: "input-available", input: undefined },
265
+ ),
261
266
  ];
262
267
  if (revealed >= DRAFT_STEPS.length) {
263
- runItems.push({ type: "text", id: "outro", text: `Drafted ${DRAFTED.length} tasks — keep the ones that look right, edit any before adding.` });
268
+ runItems.push({ type: "text", text: `Drafted ${DRAFTED.length} tasks — keep the ones that look right, edit any before adding.` });
264
269
  }
265
270
 
266
271
  const propertyCols: DataGridColumn<Task>[] = [
@@ -384,7 +389,7 @@ export function TplTaskBoard() {
384
389
  <Composer onSend={startDraft} placeholder="Paste your notes…" autoFocus sendLabel="Draft tasks" />
385
390
  </View>
386
391
  ) : null}
387
- {phase === "reading" ? <AgentRun items={runItems} state={revealed >= DRAFT_STEPS.length ? "done" : "streaming"} /> : null}
392
+ {phase === "reading" ? <AgentRun parts={runItems} state={revealed >= DRAFT_STEPS.length ? "done" : "streaming"} /> : null}
388
393
  {phase === "review" ? (
389
394
  <View style={{ gap: 8 }}>
390
395
  <ChangeReviewHeader title="Drafted tasks" />
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "13.10.0",
3
+ "version": "14.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -264,9 +264,13 @@
264
264
  "react-dom": "^19.2.0",
265
265
  "react-native": ">=0.85.0",
266
266
  "react-native-svg": ">=15.0.0",
267
- "react-native-web": ">=0.20.0"
267
+ "react-native-web": ">=0.20.0",
268
+ "ai": ">=7.0.0"
268
269
  },
269
270
  "peerDependenciesMeta": {
271
+ "ai": {
272
+ "optional": true
273
+ },
270
274
  "expo-image": {
271
275
  "optional": true
272
276
  },
@@ -4,12 +4,16 @@ import { colors } from "./colors";
4
4
  import { Text } from "./text";
5
5
  import { WaveAvatar } from "./wave_avatar";
6
6
  import { PressableHighlight } from "./pressable_highlight";
7
- import { AgentRun, resolveToolMeta, type AgentRunItem } from "./agent_run";
7
+ import { AgentRun, resolveToolMeta } from "./agent_run";
8
+ import { toSegments, lastRunningStep, type AgentUIPart } from "./agent_transform";
8
9
 
9
10
  export interface AgentProgressProps {
10
- /** The ordered transcript — same shape `AgentRun` takes. */
11
- items: AgentRunItem[];
11
+ /** The agent message's ai-sdk `parts` — same shape `AgentRun` takes. */
12
+ parts: readonly AgentUIPart[];
12
13
  state?: "streaming" | "done" | "error";
14
+ /** A run-level breaking error, forwarded to the expanded `AgentRun` (a terminal
15
+ * danger row). Lives outside `parts` — `useAgentRun().error`. */
16
+ error?: string;
13
17
  /** The collapsed-pill label. Defaults to the running step's label (or
14
18
  * "Working…" / "Done"). */
15
19
  label?: string;
@@ -30,16 +34,11 @@ export interface AgentProgressProps {
30
34
  * label, so the surface stays calm until you ask to see the steps.
31
35
  */
32
36
  export function AgentProgress(props: AgentProgressProps) {
33
- const { items, state = "streaming", label, labelForTool, stepsLabel, defaultExpanded } = props;
37
+ const { parts, state = "streaming", error, label, labelForTool, stepsLabel, defaultExpanded } = props;
34
38
  const [expanded, setExpanded] = useState(defaultExpanded ?? false);
35
39
 
36
- const running = [...items].reverse().find((it) => it.type === "step" && it.status === "running");
37
- const runningLabel =
38
- running && running.type === "step"
39
- ? running.kind === "tool"
40
- ? resolveToolMeta(running.label, labelForTool).label
41
- : running.label
42
- : undefined;
40
+ const running = lastRunningStep(toSegments(parts));
41
+ const runningLabel = running ? resolveToolMeta(running.toolName, labelForTool).label : undefined;
43
42
  const compact = label ?? runningLabel ?? (state === "done" ? "Done" : state === "error" ? "Stopped" : "Working…");
44
43
  const streaming = state === "streaming";
45
44
 
@@ -48,7 +47,7 @@ export function AgentProgress(props: AgentProgressProps) {
48
47
  {expanded ? (
49
48
  <View style={styles.panel}>
50
49
  <ScrollView style={{ maxHeight: 260 }} contentContainerStyle={{ padding: 16 }}>
51
- <AgentRun items={items} state={state} labelForTool={labelForTool} stepsLabel={stepsLabel} />
50
+ <AgentRun parts={parts} state={state} error={error} labelForTool={labelForTool} stepsLabel={stepsLabel} />
52
51
  </ScrollView>
53
52
  </View>
54
53
  ) : null}
package/src/agent_run.tsx CHANGED
@@ -5,59 +5,31 @@ import { Text } from "./text";
5
5
  import { Icon, type IconName } from "./icon";
6
6
  import { Markdown } from "./markdown";
7
7
  import { JsonPanel, stringifyData } from "./json_panel";
8
- import { Peek } from "./peek";
9
8
  import { PressableHighlight } from "./pressable_highlight";
10
9
  import { Marker, type StepStatus } from "./stepper";
11
10
  import { AnimationFadeIn } from "./animation_fade_in";
12
11
  import { CONTROL_HEIGHT, CONTROL_RADIUS } from "./control_surface";
13
12
  import { useLoticsLocale, type LoticsLocale } from "./locale";
14
13
 
15
- export type AgentStepStatus = "running" | "done" | "error";
16
-
17
- export interface AgentRunStep {
18
- id: string;
19
- /** A `tool` step carries the RAW tool name (`update_records`) — the feed maps
20
- * it to a human label via the default map / `labelForTool`. A `step` carries
21
- * an already-human label. */
22
- label: string;
23
- status: AgentStepStatus;
24
- kind?: "step" | "tool";
25
- /** The tool call's input (arguments) and result. When present (and no explicit
26
- * `peek`), the feed reveals them ON DEMAND — a press-to-open peek with an Input /
27
- * Output panel — instead of cluttering the row; the row itself shows only the
28
- * label + state. */
29
- input?: unknown;
30
- output?: unknown;
31
- /** The tool failure message (shown in the peek when `status` is `"error"`). */
32
- errorText?: string;
33
- /** A note under the label — a short, human summary (a count, a result summary),
34
- * never invented prose. Muted. For raw tool I/O prefer `input`/`output`. */
35
- detail?: string;
36
- /** Custom content for the press-to-open peek. Overrides the auto-built Input /
37
- * Output panel — pass this to render the reveal yourself. */
38
- peek?: ReactNode;
39
- }
40
-
41
- /**
42
- * The ordered transcript of a run, in the order it happened: the agent's answer
43
- * prose (`text`), its thinking (`reasoning`, shown collapsed / revealed on demand),
44
- * and the tools it runs between prose (`step`). A real run is a timeline — text, a
45
- * burst of tool calls, more text — so the feed is an ordered array, NOT a text
46
- * block over a flat step list. Consecutive `step` items collapse into ONE activity
47
- * group; a `step`'s `input`/`output` reveal in a peek on press.
48
- */
49
- export type AgentRunItem =
50
- | { type: "text"; id: string; text: string }
51
- | { type: "reasoning"; id: string; text: string }
52
- | ({ type: "step" } & AgentRunStep);
14
+ // The render model comes STRAIGHT from ai-sdk `UIMessage.parts` — no bespoke
15
+ // transcript type. `agent_transform` is the one place that folds parts into the
16
+ // timeline; this file is the renderer.
17
+ import { toSegments, anyRunning, type AgentUIPart, type AgentStep } from "./agent_transform";
53
18
 
54
19
  export interface AgentRunProps {
55
- /** The ordered transcript text + step items in the order they occurred. */
56
- items: AgentRunItem[];
57
- /** Whole-run state. `streaming` keeps the trailing text's caret blinking and
58
- * the tail activity group pulsing; `done`/`error` settle every group. Defaults
59
- * to `streaming` while any step is `running`. */
20
+ /** The agent message's ai-sdk `parts` the ordered transcript, rendered in
21
+ * place. Chat passes `message.parts`; an app passes `useAgentRun().parts`. */
22
+ parts: readonly AgentUIPart[];
23
+ /** Whole-run state. `streaming` keeps the tail activity group pulsing;
24
+ * `done`/`error` settle every group. Defaults to `streaming` while any tool
25
+ * step is running. */
60
26
  state?: "streaming" | "done" | "error";
27
+ /** A run-level BREAKING error — the message that terminated the run (a stream
28
+ * error, an API failure, a hit quota). It lives OUTSIDE `parts` (chat stores it
29
+ * in the message's `errors`; `useAgentRun().error` carries it), so pass it here;
30
+ * it renders as a terminal danger row under the transcript. A per-tool failure
31
+ * is different — it rides in that tool's own `output-error` part. */
32
+ error?: string;
61
33
  /** Localize / override a TOOL step's display label by its raw tool name
62
34
  * (`update_records` → "Đang cập nhật dữ liệu"). Return `undefined` to fall
63
35
  * back to the built-in label. */
@@ -107,36 +79,9 @@ export function resolveToolMeta(
107
79
  };
108
80
  }
109
81
 
110
- /** Resolve a step's display label a `tool` maps its raw name, a `step` is
111
- * already human. */
112
- function stepLabel(s: AgentRunStep, labelForTool?: (toolName: string) => string | undefined): string {
113
- return s.kind === "tool" ? resolveToolMeta(s.label, labelForTool).label : s.label;
114
- }
115
-
116
- // A run is a timeline of segments: prose, thinking, then a group of consecutive
117
- // tool calls, then more prose. Folding the flat item list into segments is what
118
- // lets the feed interleave instead of forcing all text to the top.
119
- type Segment =
120
- | { kind: "text"; id: string; text: string }
121
- | { kind: "reasoning"; id: string; text: string }
122
- | { kind: "group"; id: string; steps: AgentRunStep[] };
123
-
124
- function toSegments(items: AgentRunItem[]): Segment[] {
125
- const segments: Segment[] = [];
126
- for (const item of items) {
127
- if (item.type === "text" || item.type === "reasoning") {
128
- segments.push({ kind: item.type, id: item.id, text: item.text });
129
- continue;
130
- }
131
- const { type: _t, ...step } = item;
132
- const tail = segments[segments.length - 1];
133
- if (tail && tail.kind === "group") {
134
- segments[segments.length - 1] = { ...tail, steps: [...tail.steps, step] };
135
- } else {
136
- segments.push({ kind: "group", id: item.id, steps: [step] });
137
- }
138
- }
139
- return segments;
82
+ /** Resolve a tool step's display label from its raw tool name. */
83
+ function stepLabel(s: AgentStep, labelForTool?: (toolName: string) => string | undefined): string {
84
+ return resolveToolMeta(s.toolName, labelForTool).label;
140
85
  }
141
86
 
142
87
  const INK = colors.zinc[700];
@@ -147,15 +92,14 @@ const INK = colors.zinc[700];
147
92
  * tail group is a SINGLE pulsing row whose label swaps in place as each call
148
93
  * fires (not a growing stack of dots); once prose resumes the group settles into
149
94
  * one row — "{final action} · {n} steps" — that EXPANDS on press to the steps in
150
- * between. Text segments render as prose, the last one carrying a live caret. The
151
- * run always ends on the agent's text. Pair with `Composer` + `ChangeReview`.
95
+ * between. Text segments render as prose. The run always ends on the agent's text.
96
+ * Pair with `Composer` + `ChangeReview`.
152
97
  */
153
98
  export function AgentRun(props: AgentRunProps) {
154
- const { items, labelForTool, stepsLabel = (n) => `${n} steps`, accessibilityLabel } = props;
155
- const anyRunning = items.some((it) => it.type === "step" && it.status === "running");
156
- const state = props.state ?? (anyRunning ? "streaming" : "done");
99
+ const { parts, labelForTool, stepsLabel = (n) => `${n} steps`, accessibilityLabel } = props;
100
+ const segments = toSegments(parts);
101
+ const state = props.state ?? (anyRunning(segments) ? "streaming" : "done");
157
102
  const streaming = state === "streaming";
158
- const segments = toSegments(items);
159
103
  const lastIndex = segments.length - 1;
160
104
 
161
105
  const [expanded, setExpanded] = useState<Record<string, boolean>>({});
@@ -167,7 +111,7 @@ export function AgentRun(props: AgentRunProps) {
167
111
  if (seg.kind === "text") {
168
112
  return (
169
113
  <View key={seg.id} style={styles.narration}>
170
- <Markdown>{streaming && i === lastIndex ? `${seg.text} ▍` : seg.text}</Markdown>
114
+ <Markdown>{seg.text}</Markdown>
171
115
  </View>
172
116
  );
173
117
  }
@@ -186,6 +130,20 @@ export function AgentRun(props: AgentRunProps) {
186
130
  />
187
131
  );
188
132
  })}
133
+ {/* A run-level BREAKING error terminates the feed with a danger row. It lives
134
+ outside `parts` (the stream failed), so the caller passes it explicitly. */}
135
+ {props.error ? (
136
+ <View style={styles.row}>
137
+ <View style={styles.dotCol}>
138
+ <Icon name="circle-alert" size={17} color={colors.red[500]} />
139
+ </View>
140
+ <View style={styles.rowBody}>
141
+ <Text size="sm" color="danger">
142
+ {props.error}
143
+ </Text>
144
+ </View>
145
+ </View>
146
+ ) : null}
189
147
  </View>
190
148
  );
191
149
  }
@@ -213,7 +171,7 @@ function ActivityRow(props: {
213
171
  );
214
172
  if (onPress) {
215
173
  return (
216
- <PressableHighlight focusRing onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel} style={styles.row}>
174
+ <PressableHighlight focusRing userSelect="none" onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel} style={styles.row}>
217
175
  {inner}
218
176
  </PressableHighlight>
219
177
  );
@@ -221,25 +179,14 @@ function ActivityRow(props: {
221
179
  return <View style={styles.row}>{inner}</View>;
222
180
  }
223
181
 
224
- function StepBody({ label, detail, status }: { label: string; detail?: string; status: AgentStepStatus }) {
225
- return (
226
- <View style={{ gap: 1 }}>
227
- <Text size="sm">{label}</Text>
228
- {detail ? (
229
- <Text size="xs" color={status === "error" ? "danger" : "muted"}>
230
- {detail}
231
- </Text>
232
- ) : null}
233
- </View>
234
- );
182
+ function StepBody({ label }: { label: string }) {
183
+ return <Text size="sm">{label}</Text>;
235
184
  }
236
185
 
237
- // The press-to-open reveal for a tool step: the caller's `peek` wins; else Input /
238
- // Output / Error panels auto-built from the carried I/O; else nothing (no reveal).
239
- // Raw I/O stays OUT of the row — only shown here, on demand. Panel titles come from
240
- // the locale (the caller supplies a whole `peek` to override).
241
- function stepPeek(s: AgentRunStep, labels: LoticsLocale["agentRun"]): ReactNode {
242
- if (s.peek) return s.peek;
186
+ // The on-demand reveal for a tool step: Input / Output / Error panels auto-built
187
+ // from the carried I/O; nothing if there's none. Raw I/O stays OUT of the row — only
188
+ // shown here, on demand. Panel titles come from the locale.
189
+ function stepDetail(s: AgentStep, labels: LoticsLocale["agentRun"]): ReactNode {
243
190
  const hasInput = s.input !== undefined;
244
191
  const hasOutput = s.output !== undefined;
245
192
  if (!hasInput && !hasOutput && !s.errorText) return null;
@@ -255,18 +202,28 @@ function stepPeek(s: AgentRunStep, labels: LoticsLocale["agentRun"]): ReactNode
255
202
  );
256
203
  }
257
204
 
258
- // A step's body, wrapped in a Peek when there's something to reveal (I/O or a
259
- // caller peek), plain otherwise.
260
- function StepContent({ s, label }: { s: AgentRunStep; label: string }) {
205
+ // A settled step row. When the step carries I/O (or an error) it EXPANDS IN PLACE on
206
+ // press — the panels roll out under the row (same disclosure pattern as "Thinking"),
207
+ // never a popover so an errored call reads like any other step until you open it.
208
+ function StepRow({ s, label }: { s: AgentStep; label: string }) {
261
209
  const locale = useLoticsLocale();
262
- const peek = stepPeek(s, locale.agentRun);
263
- const body = <StepBody label={label} detail={s.detail} status={s.status} />;
264
- return peek ? (
265
- <Peek accessibilityLabel={label} content={peek}>
266
- {body}
267
- </Peek>
268
- ) : (
269
- body
210
+ const [open, setOpen] = useState(false);
211
+ const detail = stepDetail(s, locale.agentRun);
212
+ const marker: StepStatus = s.status === "error" ? "warning" : "done";
213
+ if (!detail) {
214
+ return (
215
+ <ActivityRow markerStatus={marker}>
216
+ <StepBody label={label} />
217
+ </ActivityRow>
218
+ );
219
+ }
220
+ return (
221
+ <View>
222
+ <ActivityRow markerStatus={marker} onPress={() => setOpen((o) => !o)} accessibilityLabel={label} trailing={chevron(open ? "up" : "down")}>
223
+ <StepBody label={label} />
224
+ </ActivityRow>
225
+ {open ? <View style={styles.rowDetail}>{detail}</View> : null}
226
+ </View>
270
227
  );
271
228
  }
272
229
 
@@ -277,7 +234,7 @@ function ReasoningDisclosure(props: { text: string; streaming?: boolean; expande
277
234
  const locale = useLoticsLocale();
278
235
  return (
279
236
  <View style={styles.group}>
280
- <PressableHighlight focusRing onPress={onToggle} accessibilityRole="button" accessibilityLabel={locale.agentRun.thinking} style={styles.row}>
237
+ <PressableHighlight focusRing userSelect="none" onPress={onToggle} accessibilityRole="button" accessibilityLabel={locale.agentRun.thinking} style={styles.row}>
281
238
  <View style={styles.dotCol}>
282
239
  <Marker status={streaming ? "current" : "done"} color={colors.zinc[400]} live={!!streaming} />
283
240
  </View>
@@ -313,7 +270,7 @@ function chevron(dir: "down" | "up") {
313
270
  }
314
271
 
315
272
  function ToolGroup(props: {
316
- steps: AgentRunStep[];
273
+ steps: AgentStep[];
317
274
  active: boolean;
318
275
  expanded: boolean;
319
276
  onToggle: () => void;
@@ -321,7 +278,7 @@ function ToolGroup(props: {
321
278
  stepsLabel: (n: number) => string;
322
279
  }) {
323
280
  const { steps, active, expanded, onToggle, labelForTool, stepsLabel } = props;
324
- const resolve = (s: AgentRunStep) => stepLabel(s, labelForTool);
281
+ const resolve = (s: AgentStep) => stepLabel(s, labelForTool);
325
282
 
326
283
  // ACTIVE — one pulsing row whose label swaps in place as each call fires (the
327
284
  // label is keyed by the current step's id, so a new call rises + fades into the
@@ -332,7 +289,7 @@ function ToolGroup(props: {
332
289
  <View style={styles.group}>
333
290
  <ActivityRow markerStatus="current" live>
334
291
  <AnimationFadeIn key={current.id} translateY={4}>
335
- <StepBody label={resolve(current)} detail={current.detail} status={current.status} />
292
+ <StepBody label={resolve(current)} />
336
293
  </AnimationFadeIn>
337
294
  </ActivityRow>
338
295
  </View>
@@ -343,13 +300,11 @@ function ToolGroup(props: {
343
300
  const final = steps[steps.length - 1];
344
301
  const expandable = steps.length > 1;
345
302
 
346
- // SETTLED, single call — just the one done/warning row (nothing to expand).
303
+ // SETTLED, single call — one done/warning row (its I/O expands in place on press).
347
304
  if (!expandable) {
348
305
  return (
349
306
  <View style={styles.group}>
350
- <ActivityRow markerStatus={final.status === "error" ? "warning" : "done"}>
351
- <StepContent s={final} label={resolve(final)} />
352
- </ActivityRow>
307
+ <StepRow s={final} label={resolve(final)} />
353
308
  </View>
354
309
  );
355
310
  }
@@ -372,9 +327,7 @@ function ToolGroup(props: {
372
327
  {expanded
373
328
  ? steps.map((s) => (
374
329
  <AnimationFadeIn key={s.id} translateY={4}>
375
- <ActivityRow markerStatus={s.status === "error" ? "warning" : "done"}>
376
- <StepContent s={s} label={resolve(s)} />
377
- </ActivityRow>
330
+ <StepRow s={s} label={resolve(s)} />
378
331
  </AnimationFadeIn>
379
332
  ))
380
333
  : null}
@@ -394,9 +347,17 @@ const styles = StyleSheet.create({
394
347
  gap: 10,
395
348
  minHeight: CONTROL_HEIGHT,
396
349
  borderRadius: CONTROL_RADIUS,
350
+ // The hover/press highlight BLEEDS 8px past the content on each side (negative
351
+ // margin absorbed by equal padding, the Peek pattern), so its rounded edges
352
+ // never pinch against the leading dot or the trailing chevron. Content stays
353
+ // flush-aligned (net zero), so the run's left edge still meets the consumer's gutter.
354
+ marginHorizontal: -8,
355
+ paddingHorizontal: 8,
397
356
  },
398
357
  dotCol: { width: 18, alignItems: "center" },
399
358
  rowBody: { flex: 1 },
400
359
  // Reasoning body aligns under the "Thinking" label (past the dot column + gap).
401
360
  reasoning: { paddingLeft: 28, paddingBottom: 4 },
361
+ // A step's expanded I/O panels — same left edge as the reasoning body.
362
+ rowDetail: { paddingLeft: 28, paddingBottom: 6, paddingTop: 2 },
402
363
  });
@@ -0,0 +1,102 @@
1
+ import type { UIMessagePart, UIDataTypes, UITools } from "ai";
2
+
3
+ // `@lotics/ui` renders an agent run straight from the ai-sdk `UIMessage.parts` — the
4
+ // canonical, versioned wire shape BOTH the chat and app agents already emit. There
5
+ // is no bespoke transcript type: this module is the one place that folds parts into
6
+ // the render timeline, shared by `AgentRun` and `AgentProgress`. Consumers pass their
7
+ // message's `parts` (chat's static `tool-<name>` parts and the app SDK's
8
+ // `dynamic-tool` parts are both handled).
9
+
10
+ /** An ai-sdk message part, tool-set-agnostic (we only read `type` + a few fields). */
11
+ export type AgentUIPart = UIMessagePart<UIDataTypes, UITools>;
12
+
13
+ /** A tool step's settle state, collapsed from ai's 7-state tool machine. */
14
+ export type AgentStepStatus = "running" | "done" | "error";
15
+
16
+ /** One tool call, reduced to what the feed shows. `toolName` is the RAW name — the
17
+ * renderer maps it to a human label + icon (`resolveToolMeta`). */
18
+ export interface AgentStep {
19
+ id: string;
20
+ toolName: string;
21
+ status: AgentStepStatus;
22
+ /** Revealed ON DEMAND (a press-to-open peek), never inline. */
23
+ input?: unknown;
24
+ output?: unknown;
25
+ errorText?: string;
26
+ }
27
+
28
+ /** The render timeline: prose, thinking, and groups of consecutive tool calls, in
29
+ * the order they streamed. */
30
+ export type AgentSegment =
31
+ | { kind: "text"; id: string; text: string }
32
+ | { kind: "reasoning"; id: string; text: string }
33
+ | { kind: "group"; id: string; steps: AgentStep[] };
34
+
35
+ // A tool part — static (`tool-<name>`) or `dynamic-tool` — is exactly the part union
36
+ // that carries `toolCallId`, so extracting on that field is a sound, cast-free guard.
37
+ function isToolPart(part: AgentUIPart): part is Extract<AgentUIPart, { toolCallId: string }> {
38
+ return part.type === "dynamic-tool" || part.type.startsWith("tool-");
39
+ }
40
+
41
+ // ai's tool state machine → the feed's 3 states. Approval-pending/responded read as
42
+ // "running" (a read-only feed has no approval affordance); denied reads as an error.
43
+ function toStatus(state: string): AgentStepStatus {
44
+ if (state === "output-available") return "done";
45
+ if (state === "output-error" || state === "output-denied") return "error";
46
+ return "running";
47
+ }
48
+
49
+ function toStep(part: Extract<AgentUIPart, { toolCallId: string }>): AgentStep {
50
+ return {
51
+ id: part.toolCallId,
52
+ toolName: part.type === "dynamic-tool" ? part.toolName : part.type.slice("tool-".length),
53
+ status: toStatus(part.state),
54
+ input: part.input,
55
+ output: part.state === "output-available" ? part.output : undefined,
56
+ errorText: part.state === "output-error" ? part.errorText : undefined,
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Fold a message's parts into the render timeline. Consecutive tool parts collapse
62
+ * into ONE group (the feed shows a burst as a single expandable row). Prose parts
63
+ * carry no id in ai's model, so a stable index-based one is synthesized. Part kinds
64
+ * with no feed representation (`step-start`, `file`, `source-*`, `data-*`, `custom`)
65
+ * are skipped — the same kinds the chat's own renderer ignores.
66
+ */
67
+ export function toSegments(parts: readonly AgentUIPart[]): AgentSegment[] {
68
+ const segments: AgentSegment[] = [];
69
+ parts.forEach((part, i) => {
70
+ if (part.type === "text") {
71
+ if (part.text) segments.push({ kind: "text", id: `text-${i}`, text: part.text });
72
+ return;
73
+ }
74
+ if (part.type === "reasoning") {
75
+ if (part.text) segments.push({ kind: "reasoning", id: `reasoning-${i}`, text: part.text });
76
+ return;
77
+ }
78
+ if (!isToolPart(part)) return;
79
+ const step = toStep(part);
80
+ const tail = segments[segments.length - 1];
81
+ if (tail?.kind === "group") tail.steps.push(step);
82
+ else segments.push({ kind: "group", id: step.id, steps: [step] });
83
+ });
84
+ return segments;
85
+ }
86
+
87
+ /** Whether any tool step is still running — the default "is the run streaming" signal. */
88
+ export function anyRunning(segments: readonly AgentSegment[]): boolean {
89
+ return segments.some((s) => s.kind === "group" && s.steps.some((st) => st.status === "running"));
90
+ }
91
+
92
+ /** The last still-running tool step, if any — for a compact "currently doing X". */
93
+ export function lastRunningStep(segments: readonly AgentSegment[]): AgentStep | undefined {
94
+ for (let i = segments.length - 1; i >= 0; i--) {
95
+ const seg = segments[i];
96
+ if (seg.kind !== "group") continue;
97
+ for (let j = seg.steps.length - 1; j >= 0; j--) {
98
+ if (seg.steps[j].status === "running") return seg.steps[j];
99
+ }
100
+ }
101
+ return undefined;
102
+ }
@@ -1,4 +1,4 @@
1
- import { Platform, StyleSheet, View } from "react-native";
1
+ import { Platform, ScrollView, StyleSheet, View } from "react-native";
2
2
  import { Text } from "./text";
3
3
  import { colors } from "./colors";
4
4
 
@@ -15,18 +15,22 @@ export function JsonPanel(props: JsonPanelProps) {
15
15
  {title}
16
16
  </Text>
17
17
  <View style={styles.body}>
18
- <Text
19
- size="xs"
20
- style={{
21
- fontFamily:
22
- Platform.OS === "web"
23
- ? "ui-monospace, SFMono-Regular, Menlo, monospace"
24
- : "monospace",
25
- lineHeight: 18,
26
- }}
27
- >
28
- {value}
29
- </Text>
18
+ {/* A real dump can be tens of KB (a streamed tool input) — the panel caps
19
+ its height and scrolls inside, so it can never blow a feed open. */}
20
+ <ScrollView style={styles.scroll} contentContainerStyle={styles.content} nestedScrollEnabled>
21
+ <Text
22
+ size="xs"
23
+ style={{
24
+ fontFamily:
25
+ Platform.OS === "web"
26
+ ? "ui-monospace, SFMono-Regular, Menlo, monospace"
27
+ : "monospace",
28
+ lineHeight: 18,
29
+ }}
30
+ >
31
+ {value}
32
+ </Text>
33
+ </ScrollView>
30
34
  </View>
31
35
  </View>
32
36
  );
@@ -50,6 +54,14 @@ const styles = StyleSheet.create({
50
54
  borderColor: colors.zinc[200],
51
55
  borderRadius: 8,
52
56
  backgroundColor: colors.white,
57
+ // The ScrollView owns the padding (content inset scrolls with the text); the
58
+ // frame just clips so the scrollbar respects the rounded corners.
59
+ overflow: "hidden",
60
+ },
61
+ scroll: {
62
+ maxHeight: 240,
63
+ },
64
+ content: {
53
65
  paddingHorizontal: 10,
54
66
  paddingVertical: 8,
55
67
  },
@@ -21,18 +21,31 @@ import * as path from "node:path";
21
21
  * import `./markdown`. So it's allowed — the guards below remain for the
22
22
  * genuine context couplings.
23
23
  *
24
+ * The ONE allowed `ai` coupling: the `AgentRun` family renders the ai-sdk
25
+ * `UIMessage.parts` shape directly — that IS its contract (both chat and app
26
+ * agents emit it), so there is no bespoke transcript type to keep in sync.
27
+ * `agent_transform.ts` (the single place that folds parts into the render
28
+ * timeline) `import type`s from `ai` — erased at runtime, so no `ai` enters any
29
+ * consumer's bundle. That file alone is exempt from the `ai`-package guard; every
30
+ * other primitive stays ai-free, and the `@ai-sdk/*` runtime packages are never
31
+ * allowed anywhere.
32
+ *
24
33
  * The boundary is enforced by this test — without it primitives drift
25
34
  * back into the parent app's contexts over months.
26
35
  */
27
36
 
37
+ const AI_PACKAGE_PATTERN = /from\s+["']ai["']/;
28
38
  const FORBIDDEN_PATTERNS = [
29
39
  /from\s+["']posthog-js/,
30
40
  /from\s+["']@lingui\//,
31
41
  /from\s+["']@ai-sdk\//,
32
- /from\s+["']ai["']/,
42
+ AI_PACKAGE_PATTERN,
33
43
  /from\s+["']@lotics\/shared/,
34
44
  ];
35
45
 
46
+ // The sole file permitted to `import type` from the `ai` package (see above).
47
+ const AI_PACKAGE_ALLOWED = new Set(["agent_transform.ts"]);
48
+
36
49
  const SRC_DIR = path.resolve(__dirname);
37
50
 
38
51
  function listTopLevelSourceFiles(): string[] {
@@ -49,6 +62,7 @@ describe("packages/ui primitives — purity", () => {
49
62
  for (const file of listTopLevelSourceFiles()) {
50
63
  const src = fs.readFileSync(file, "utf-8");
51
64
  for (const pattern of FORBIDDEN_PATTERNS) {
65
+ if (pattern === AI_PACKAGE_PATTERN && AI_PACKAGE_ALLOWED.has(path.basename(file))) continue;
52
66
  if (pattern.test(src)) {
53
67
  violations.push(`${path.basename(file)} matches ${pattern}`);
54
68
  }