@lotics/ui 14.3.1 → 15.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -1,31 +1,5 @@
1
1
  # @lotics/ui — the UI reference index
2
2
 
3
- > **Upgrading to v13 from 12.x.** `RadioPicker`'s **`direction`** prop was removed — the
4
- > picker is always the stacked column of full-width choices. A former `direction="row"`
5
- > usage just drops the prop. Additively, `DetailRow` and `FormField` gained a **`warning`**
6
- > field annotation (amber, the third tone between `description` and `error`).
7
-
8
- > **Upgrading to v12 from 11.x.** Two exports were removed: **`CommentComposer`** (compose
9
- > comments with THE kit `Composer` — attach via `actionsButton`/`files`; `CommentList` is
10
- > unchanged) and **`DetailRow.info`** (field annotations are explicit — use the new
11
- > **`DetailRow.description`** under the value and **`DetailRow.error`**, the `FormField`
12
- > vocabulary; heading/card `info` ⓘ props are untouched). Visual behavior changes:
13
- > `SubsectionStack` is SPACE-ONLY (32px beat, no hairlines — titles carry the grouping),
14
- > `Section` heading gap 12→16, `DetailTable` row gap 6→8, `DetailRow` rows top-align with
15
- > label/control/trailing centered on the first control line, `Timeline` grew to 32px
16
- > discs / 16px icons / 40px minimum rows, `TextInputField` paints a WHITE surface on any
17
- > background (it was transparent — identical on the white canvas, visible on tinted
18
- > panels), and the inline editors' resting chip gained 6px vertical padding (identical for
19
- > one-line values; multi-line resting content now breathes).
20
-
21
- > **Upgrading to v11 from ≤10.x — the type scale was re-slotted.** The display sizes gained the
22
- > missing `##` step: `xl` is now 22/24 (section title), `xxl` is 28/32 (page/record title — the
23
- > OLD `xl` look), and the new `xxxl` is 32/48 (hero numbers — the OLD `xxl` look). Migrate an
24
- > app with two renames IN THIS ORDER: first `size="xxl"` → `size="xxxl"`, then `size="xl"` →
25
- > `size="xxl"` — every existing usage keeps its exact look. Also: `SectionHeadingTitle` is now
26
- > ALWAYS `##` (22/24), and disabled `Button`s fade their label to the zinc-400 disabled ink on
27
- > every variant EXCEPT `primary`, which keeps inverted ink over its washed surface.
28
-
29
3
  The component kit for Lotics custom-code apps and the product frontend: React-Native-Web
30
4
  primitives (renders on web **and** native), data-entry patterns, AI surfaces, a composition
31
5
  grammar, and worked-example screens. It pairs with **`@lotics/app-sdk`** (data + RPC — read
@@ -33,7 +7,8 @@ grammar, and worked-example screens. It pairs with **`@lotics/app-sdk`** (data +
33
7
 
34
8
  This file is the index. The comprehensive references live in **`docs/`** — read the owning area
35
9
  doc before building any screen, **never from memory**. Exact props are the shipped sources
36
- (`src/<name>.tsx`); full worked screens are `examples/tpl_*.tsx`.
10
+ (`src/<name>.tsx`); full worked screens are `examples/tpl_*.tsx`. Everything here describes the
11
+ CURRENT major only — upgrading an app across majors is `MIGRATION.md`.
37
12
 
38
13
  ## The area references
39
14
 
package/MIGRATION.md ADDED
@@ -0,0 +1,44 @@
1
+ # @lotics/ui — migration notes
2
+
3
+ Breaking changes per major, newest first. The current contract lives in `AGENTS.md` + `docs/`;
4
+ this file exists only to move an app from one major to the next.
5
+
6
+ ## v15 from 14.x
7
+
8
+ `AgentRun`/`AgentProgress`'s **`labelForTool`** prop was replaced by **`labelForCall`** — the
9
+ callback now receives the whole call (`AgentToolCall = { toolName, input, state }`) instead of
10
+ just the name, so labels can vary by input and by the new **`awaiting`** step state (ai's
11
+ `approval-requested`, rendered as an amber parked row). Additively: `renderToolOutput` (replace
12
+ an expanded step's Output panel), `onRetry` (a retry button under the terminal error row), and
13
+ the new `ApprovalPrompt` + `MessageActions` components with their locale slices.
14
+
15
+ ## v13 from 12.x
16
+
17
+ `RadioPicker`'s **`direction`** prop was removed — the picker is always the stacked column of
18
+ full-width choices. A former `direction="row"` usage just drops the prop. Additively, `DetailRow`
19
+ and `FormField` gained a **`warning`** field annotation (amber, the third tone between
20
+ `description` and `error`).
21
+
22
+ ## v12 from 11.x
23
+
24
+ Two exports were removed: **`CommentComposer`** (compose comments with THE kit `Composer` —
25
+ attach via `actionsButton`/`files`; `CommentList` is unchanged) and **`DetailRow.info`** (field
26
+ annotations are explicit — use the new **`DetailRow.description`** under the value and
27
+ **`DetailRow.error`**, the `FormField` vocabulary; heading/card `info` ⓘ props are untouched).
28
+ Visual behavior changes: `SubsectionStack` is SPACE-ONLY (32px beat, no hairlines — titles carry
29
+ the grouping), `Section` heading gap 12→16, `DetailTable` row gap 6→8, `DetailRow` rows top-align
30
+ with label/control/trailing centered on the first control line, `Timeline` grew to 32px discs /
31
+ 16px icons / 40px minimum rows, `TextInputField` paints a WHITE surface on any background (it was
32
+ transparent — identical on the white canvas, visible on tinted panels), and the inline editors'
33
+ resting chip gained 6px vertical padding (identical for one-line values; multi-line resting
34
+ content now breathes).
35
+
36
+ ## v11 from ≤10.x
37
+
38
+ The type scale was re-slotted — the display sizes gained the missing `##` step: `xl` is now 22/24
39
+ (section title), `xxl` is 28/32 (page/record title — the OLD `xl` look), and the new `xxxl` is
40
+ 32/48 (hero numbers — the OLD `xxl` look). Migrate an app with two renames IN THIS ORDER: first
41
+ `size="xxl"` → `size="xxxl"`, then `size="xl"` → `size="xxl"` — every existing usage keeps its
42
+ exact look. Also: `SectionHeadingTitle` is now ALWAYS `##` (22/24), and disabled `Button`s fade
43
+ their label to the zinc-400 disabled ink on every variant EXCEPT `primary`, which keeps inverted
44
+ ink over its washed surface.
@@ -111,7 +111,10 @@ text, NOT all text on top of a flat step list. Content parts with no feed repres
111
111
  tool part's `input`/`output` are hidden in the row and **expand in place** on press — auto-built
112
112
  Input / Output `JsonPanel`s roll out under the row (the same disclosure pattern as "Thinking",
113
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.
114
+ amber and its `errorText` shows in the expanded Error panel. To render a tool's OUTPUT yourself
115
+ (an image preview, a table) pass **`renderToolOutput`** — the ONLY render escape hatch: it fires
116
+ when an output-carrying step is expanded, and a non-`undefined` return replaces the default
117
+ Output panel; `undefined` keeps it. Input + error rendering are untouched.
115
118
 
116
119
  **Activity grouping.** Consecutive tool parts fold into ONE group. While the agent is mid-tools
117
120
  the tail group is a SINGLE pulsing row whose label swaps in place as each call fires (no growing
@@ -123,18 +126,24 @@ no global terminal node.
123
126
 
124
127
  **Tool labels.** A tool part carries the RAW tool name (`update_records` from `tool-update_records`,
125
128
  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`.
129
+ tools + an optional **`labelForCall`** override (localize THERE — the kit stays English). It gets
130
+ the whole call `{ toolName, input, state }` (`AgentToolCall`) so a caller can phrase by what the
131
+ call targets or by whether it's awaiting; return `undefined` to fall back. Unknown names fall back
132
+ to a prettified form. `stepsLabel` localizes the "{n} steps" suffix. The tool `state` folds ai's
133
+ 7-state lifecycle to **running / awaiting / done / error**: `approval-requested` `awaiting` (the
134
+ call is parked on a human decision — an amber dot + an "awaiting" annotation, and it NEVER pulses,
135
+ because it isn't working); `approval-responded` → running; `output-denied` → error carrying the
136
+ denial reason. The whole-run `state` prop (`"streaming" | "done" | "error"`) defaults to `streaming`
137
+ while any tool is running, else `done` (an awaiting call is not running, so it settles).
131
138
 
132
139
  **Errors — two kinds, both surfaced, neither shouting.** An INTERMEDIARY tool failure rides in
133
140
  that tool's `output-error` part and flows like a normal call — the amber dot marks it (a settled
134
141
  group's header goes amber too), the reason waits in the expanded Error panel; real runs retry and
135
142
  move on, so the feed doesn't dramatize it. A BREAKING error that terminates the whole run lives
136
143
  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.
144
+ pass it as the `error` prop and it renders as a terminal danger row under the transcript. Pass
145
+ **`onRetry`** alongside it and a Retry action (a secondary `Button`) rides under that row so the
146
+ operator can re-fire the run; omit it and the error row renders exactly as before.
138
147
 
139
148
  **In a bounded container, wrap it in `FollowScroll`.** An `AgentRun` streaming inside a dialog,
140
149
  drawer, or fixed-height panel grows BELOW the fold — a plain scroll container doesn't follow. Wrap
@@ -155,20 +164,33 @@ install; the imports are type-only (purity-enforced), so no `ai` runtime ever en
155
164
  **Reasoning only shows when the model produces it** — the declared model must support thinking
156
165
  (Sonnet / Opus, not Haiku); the renderer shows every reasoning part it's handed.
157
166
 
158
- **Limitation:** the collapsed reasoning row's "Thinking" label and the expanded detail's
159
- "Input"/"Output"/"Error" panel titles are currently English-only (not on the locale provider and
160
- not prop-overridable); `labelForTool`/`stepsLabel` localize everything else in the feed.
167
+ **Localization.** The feed's fixed chrome — the "Thinking" label, the "Input"/"Output"/"Error"
168
+ panel titles, the "awaiting" annotation, and the "Retry" action — resolves through the
169
+ `LoticsLocale` `agentRun` slice (translate once at the provider; a `vi` app gets them for free).
170
+ It is provider-localized, not per-instance prop-overridable; `labelForCall`/`stepsLabel` localize
171
+ the tool labels and the "{n} steps" suffix per call site.
172
+
173
+ **Answering an awaiting call — `ApprovalPrompt`.** The `awaiting` row inside `AgentRun` is
174
+ READ-ONLY — it only NAMES that a call is parked on a human decision. The surface that ANSWERS it
175
+ is `ApprovalPrompt` (`@lotics/ui/approval_prompt`): a warning icon + `message` (per-instance
176
+ override, else the `approvalPrompt` slice default) on a bordered card, with **Deny** (secondary)
177
+ + **Approve** (primary) right-aligned and an optional `children` slot for an input summary between
178
+ them — `onApprove`/`onDeny` fire the SDK's approve/deny (the ai-sdk approval vocabulary). Slot it
179
+ where the composer normally sits while the run is parked, so the operator reads the parked row in
180
+ the feed and answers it below; on decision the run resumes (`approval-responded` → running) or the
181
+ call settles denied (`output-denied` → error). Its strings resolve prop → the `approvalPrompt`
182
+ locale slice → English.
161
183
 
162
184
  ### `AgentProgress` — the floating pill
163
185
 
164
186
  On a canvas/composer app reach for `AgentProgress` (`@lotics/ui/agent_progress`) — `AgentRun`
165
187
  collapsed into a floating pill (an animated `WaveAvatar` + the current step's label) that EXPANDS
166
- on press to the full feed in a capped scroll panel. It takes the same `items`/`state`/
167
- `labelForTool`/`stepsLabel`, plus `label` (override the collapsed text defaults to the running
168
- step's label, else "Working…" / "Done" / "Stopped"; pass it to localize — note it's one static
169
- string, so it no longer tracks the running step) and `defaultExpanded`. Its pill matches
170
- `Composer`'s compact geometry, so the composer morphs into it while running and reveals again
171
- when done.
188
+ on press to the full feed in a capped scroll panel. It takes the same `parts`/`state`/`error`, and
189
+ forwards `labelForCall`/`renderToolOutput`/`onRetry`/`stepsLabel` to the expanded feed, plus `label`
190
+ (override the collapsed text — defaults to the running step's label, else "Working…" / "Done" /
191
+ "Stopped"; pass it to localize — note it's one static string, so it no longer tracks the running
192
+ step) and `defaultExpanded`. Its pill matches `Composer`'s compact geometry, so the composer morphs
193
+ into it while running and reveals again when done.
172
194
 
173
195
  **Limitation:** the pill's expand/collapse accessibility labels ("Show the agent's steps" /
174
196
  "Hide the agent's steps") are currently hardcoded English.
package/docs/catalog.md CHANGED
@@ -263,7 +263,14 @@ a plain `metric` prop remains for one-number findings. The children slot compose
263
263
  result — a compact `Table` for per-line detail (danger color on the offending cells),
264
264
  `ProgressBar` for consumption-toward-a-cliff (free time, credit), dot `Badge`s for a
265
265
  present/missing checklist, `Confidence` for judgment calls. Display-only — it informs the
266
- verdict the host records; `finding` locale slice).
266
+ verdict the host records; `finding` locale slice). `ApprovalPrompt` (the surface that ANSWERS
267
+ an agent's approval-gated action — the interactive counterpart to `AgentRun`'s read-only amber
268
+ `awaiting` row: a warning icon + message on a bordered card, Deny/Approve right-aligned, an
269
+ optional `children` input summary; slot it where the composer sits while the run is parked;
270
+ `approvalPrompt` locale slice), `MessageActions` (THE single shared per-message chrome bar for a
271
+ chat transcript — copy with owned copied-feedback, regenerate, edit, and the branch/version
272
+ pager as an internal part; each action renders only when its handler is given, so the assistant
273
+ bar and the user bar are one component with different handlers; `messageActions` locale slice).
267
274
 
268
275
  ---
269
276
 
@@ -986,20 +993,40 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
986
993
  - **`composer`** — `Composer`: the adaptive command/chat composer — a compact pill that
987
994
  expands for long text + attachments (`pills` + attachment slots, controlled or
988
995
  uncontrolled `value`, `onSend`/`onStop`, `sendDisabled` override).
989
- - **`agent_run`** — `AgentRun` + `resolveToolMeta`: the live streaming work feed. Its prop is the
990
- agent message's ai-sdk `parts` (`UIMessagePart[]` — text, reasoning, and tool parts), fed straight
991
- from `useAgentRun().parts`; renders prose, a collapsed "Thinking" disclosure, and grouped tool
992
- steps whose I/O expands in place on press (no popover). A per-tool failure shows amber with the
993
- reason in its expanded Error panel; a run-level
994
- breaking `error` (outside `parts`) renders as a terminal danger row. Needs `ai` as an optional
995
- type-only peer. AI work ONLY
996
- deterministic work (template fill, an export) shows a `loading` state on its trigger and delivers
997
- at once; dressing it as an agent run is theater.
996
+ - **`agent_run`** — `AgentRun` + `resolveToolMeta` + `AgentToolCall` + `AgentToolOutput` (the
997
+ `renderToolOutput` call shape `{ toolName, input, output }`): the live streaming work feed.
998
+ Its prop is the agent message's ai-sdk `parts` (`UIMessagePart[]` — text, reasoning, and tool
999
+ parts), fed straight from `useAgentRun().parts`; renders prose, a collapsed "Thinking" disclosure,
1000
+ and grouped tool steps whose I/O expands in place on press (no popover). A tool `state` folds ai's
1001
+ 7-state lifecycle to running / **awaiting** / done / error an `awaiting` call (parked on a human
1002
+ approval) wears an amber dot + an "awaiting" annotation and never pulses. `labelForCall(call)`
1003
+ localizes/overrides a step's label from the whole call (`AgentToolCall` = `{ toolName, input,
1004
+ state }`); `renderToolOutput(call)` replaces a step's default Output panel (the one render escape
1005
+ hatch — input/error untouched). A per-tool failure shows amber with the reason in its expanded
1006
+ Error panel; a run-level breaking `error` (outside `parts`) renders as a terminal danger row, with
1007
+ an optional `onRetry` Button under it. Fixed chrome (Thinking / Input / Output / Error / awaiting /
1008
+ Retry) localizes via the `agentRun` locale slice. Needs `ai` as an optional type-only peer. AI
1009
+ work ONLY — deterministic work (template fill, an export) shows a `loading` state on its trigger
1010
+ and delivers at once; dressing it as an agent run is theater.
998
1011
  - **`agent_transform`** — internal (no component): the one place that folds an ai-sdk `UIMessage`'s
999
1012
  `parts` into the render timeline (`AgentUIPart`, `toSegments`, `lastRunningStep`), shared by
1000
1013
  `AgentRun` and `AgentProgress`. The sole file permitted to `import type` from `ai`.
1001
1014
  - **`agent_progress`** — `AgentProgress`: `AgentRun`'s compact, floating, expandable form (same
1002
1015
  `parts` prop) — a composer's working state.
1016
+ - **`approval_prompt`** — `ApprovalPrompt`: the surface that ANSWERS an agent's approval-gated
1017
+ action — the interactive counterpart to `AgentRun`'s read-only amber `awaiting` row. A warning
1018
+ icon + `message` (per-instance override; else the slice default) on a bordered card, `onDeny`
1019
+ (secondary) + `onApprove` (primary) right-aligned, an optional `children` slot for an input
1020
+ summary between them. Approve/deny is the ai-sdk approval vocabulary. Slot it where the composer
1021
+ sits while the run is parked; the `approvalPrompt` locale slice.
1022
+ - **`message_actions`** — `MessageActions` + `MessageActionsBranch`: THE single shared per-message
1023
+ action bar for a chat transcript — `onCopy` (the bar OWNS the copied feedback: the icon flips to
1024
+ a check + the tooltip to "Copied" for ~2s, timer cleared on unmount; the handler only does the
1025
+ clipboard write), `onRegenerate`, `onEdit`, and `branch` (the sibling-version pager, absorbed as
1026
+ an internal part — renders when `total > 1`). Each action renders ONLY when its handler is given,
1027
+ so an assistant bar (copy + regenerate) and a user bar (copy + edit) are the same component with
1028
+ a different handler set. The bar hugs its content — the parent aligns it (left under an assistant
1029
+ turn, right under a user bubble); the `messageActions` locale slice.
1003
1030
  - **`follow_scroll`** — `FollowScroll`: a scroll container that FOLLOWS its growing content —
1004
1031
  the chat message list's inverted mechanism packaged as a wrapper (single-cell inverted list),
1005
1032
  so the follow happens at LAYOUT level: each growth paints already pinned (no flash), scrolling
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "14.3.1",
3
+ "version": "15.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -80,6 +80,8 @@
80
80
  "./activity_indicator": "./src/activity_indicator.tsx",
81
81
  "./agent_run": "./src/agent_run.tsx",
82
82
  "./agent_progress": "./src/agent_progress.tsx",
83
+ "./approval_prompt": "./src/approval_prompt.tsx",
84
+ "./message_actions": "./src/message_actions.tsx",
83
85
  "./follow_scroll": "./src/follow_scroll.tsx",
84
86
  "./markdown": {
85
87
  "react-native": "./src/markdown.tsx",
@@ -234,6 +236,7 @@
234
236
  "src",
235
237
  "examples",
236
238
  "AGENTS.md",
239
+ "MIGRATION.md",
237
240
  "docs"
238
241
  ],
239
242
  "publishConfig": {
@@ -5,7 +5,7 @@ import { Text } from "./text";
5
5
  import { WaveAvatar } from "./wave_avatar";
6
6
  import { FollowScroll } from "./follow_scroll";
7
7
  import { PressableHighlight } from "./pressable_highlight";
8
- import { AgentRun, resolveToolMeta } from "./agent_run";
8
+ import { AgentRun, resolveToolMeta, type AgentRunProps, type AgentToolCall } from "./agent_run";
9
9
  import { toSegments, lastRunningStep, type AgentUIPart } from "./agent_transform";
10
10
 
11
11
  export interface AgentProgressProps {
@@ -18,9 +18,13 @@ export interface AgentProgressProps {
18
18
  /** The collapsed-pill label. Defaults to the running step's label (or
19
19
  * "Working…" / "Done"). */
20
20
  label?: string;
21
- /** Localize / override a tool step's label by raw tool name — applied to both
22
- * the collapsed pill and the expanded feed. */
23
- labelForTool?: (toolName: string) => string | undefined;
21
+ /** Localize / override a tool step's label from the CALL — applied to both the
22
+ * collapsed pill and the expanded feed. */
23
+ labelForCall?: (call: AgentToolCall) => string | undefined;
24
+ /** Replace a tool step's default output panel in the expanded feed. */
25
+ renderToolOutput?: AgentRunProps["renderToolOutput"];
26
+ /** Retry action under the expanded feed's terminal error row. */
27
+ onRetry?: () => void;
24
28
  /** Localized "{n} steps" suffix, forwarded to the expanded feed. */
25
29
  stepsLabel?: (n: number) => string;
26
30
  defaultExpanded?: boolean;
@@ -35,11 +39,11 @@ export interface AgentProgressProps {
35
39
  * label, so the surface stays calm until you ask to see the steps.
36
40
  */
37
41
  export function AgentProgress(props: AgentProgressProps) {
38
- const { parts, state = "streaming", error, label, labelForTool, stepsLabel, defaultExpanded } = props;
42
+ const { parts, state = "streaming", error, label, labelForCall, renderToolOutput, onRetry, stepsLabel, defaultExpanded } = props;
39
43
  const [expanded, setExpanded] = useState(defaultExpanded ?? false);
40
44
 
41
45
  const running = lastRunningStep(toSegments(parts));
42
- const runningLabel = running ? resolveToolMeta(running.toolName, labelForTool).label : undefined;
46
+ const runningLabel = running ? resolveToolMeta({ toolName: running.toolName, input: running.input, state: running.status }, labelForCall).label : undefined;
43
47
  const compact = label ?? runningLabel ?? (state === "done" ? "Done" : state === "error" ? "Stopped" : "Working…");
44
48
  const streaming = state === "streaming";
45
49
 
@@ -50,7 +54,7 @@ export function AgentProgress(props: AgentProgressProps) {
50
54
  {/* FollowScroll keeps the newest step in view as the run streams —
51
55
  a plain ScrollView would let new content grow below the fold. */}
52
56
  <FollowScroll style={{ maxHeight: 260 }} contentContainerStyle={{ padding: 16 }}>
53
- <AgentRun parts={parts} state={state} error={error} labelForTool={labelForTool} stepsLabel={stepsLabel} />
57
+ <AgentRun parts={parts} state={state} error={error} labelForCall={labelForCall} renderToolOutput={renderToolOutput} onRetry={onRetry} stepsLabel={stepsLabel} />
54
58
  </FollowScroll>
55
59
  </View>
56
60
  ) : null}
package/src/agent_run.tsx CHANGED
@@ -2,6 +2,7 @@ import { useState, type ReactNode } from "react";
2
2
  import { StyleSheet, View } from "react-native";
3
3
  import { colors } from "./colors";
4
4
  import { Text } from "./text";
5
+ import { Button } from "./button";
5
6
  import { Icon, type IconName } from "./icon";
6
7
  import { Markdown } from "./markdown";
7
8
  import { JsonPanel, stringifyData } from "./json_panel";
@@ -14,7 +15,24 @@ import { useLoticsLocale, type LoticsLocale } from "./locale";
14
15
  // The render model comes STRAIGHT from ai-sdk `UIMessage.parts` — no bespoke
15
16
  // transcript type. `agent_transform` is the one place that folds parts into the
16
17
  // timeline; this file is the renderer.
17
- import { toSegments, anyRunning, type AgentUIPart, type AgentStep } from "./agent_transform";
18
+ import { toSegments, anyRunning, type AgentUIPart, type AgentStep, type AgentStepStatus } from "./agent_transform";
19
+
20
+ /** A resolved tool call as a `labelForCall` sees it: the RAW tool name, the
21
+ * input it was invoked with, and its settle state. Richer than a name-only map,
22
+ * so a caller can phrase a call by what it targets or by whether it's awaiting. */
23
+ export interface AgentToolCall {
24
+ toolName: string;
25
+ input: unknown;
26
+ state: AgentStepStatus;
27
+ }
28
+
29
+ /** A settled tool call as `renderToolOutput` sees it — the output stands in for
30
+ * the state (only an output-carrying step reaches the renderer). */
31
+ export interface AgentToolOutput {
32
+ toolName: string;
33
+ input: unknown;
34
+ output: unknown;
35
+ }
18
36
 
19
37
  export interface AgentRunProps {
20
38
  /** The agent message's ai-sdk `parts` — the ordered transcript, rendered in
@@ -30,10 +48,20 @@ export interface AgentRunProps {
30
48
  * it renders as a terminal danger row under the transcript. A per-tool failure
31
49
  * is different — it rides in that tool's own `output-error` part. */
32
50
  error?: string;
33
- /** Localize / override a TOOL step's display label by its raw tool name
34
- * (`update_records` → "Đang cập nhật dữ liệu"). Return `undefined` to fall
51
+ /** Localize / override a TOOL step's display label from the CALL — its raw
52
+ * name, input, and state (`update_records` → "Đang cập nhật dữ liệu"; or a
53
+ * distinct phrasing while `state === "awaiting"`). Return `undefined` to fall
35
54
  * back to the built-in label. */
36
- labelForTool?: (toolName: string) => string | undefined;
55
+ labelForCall?: (call: AgentToolCall) => string | undefined;
56
+ /** Replace the DEFAULT `JsonPanel` output rendering of an expanded tool step —
57
+ * the only render escape hatch. Called only when a step carrying an output is
58
+ * actually EXPANDED; a non-`undefined` return replaces the output panel (an
59
+ * image preview, a table), while `undefined` keeps the default panel. Input +
60
+ * error rendering are unaffected. */
61
+ renderToolOutput?: (call: AgentToolOutput) => ReactNode | undefined;
62
+ /** When the run ended on a terminal `error`, render a retry action under that
63
+ * danger row. Omit it and the error row renders exactly as before. */
64
+ onRetry?: () => void;
37
65
  /** Localized "{n} steps" suffix on a collapsed activity group (the kit is
38
66
  * locale-neutral). Default `"{n} steps"`. */
39
67
  stepsLabel?: (n: number) => string;
@@ -42,7 +70,7 @@ export interface AgentRunProps {
42
70
 
43
71
  // ── Tool → display meta ───────────────────────────────────────────────────────
44
72
  // The bounded set of platform tools an app agent can call. English by default
45
- // (the kit is locale-neutral; apps localize via `labelForTool`). A tool the map
73
+ // (the kit is locale-neutral; apps localize via `labelForCall`). A tool the map
46
74
  // doesn't know falls back to a prettified name + a neutral icon.
47
75
  const TOOL_META: Record<string, { label: string; icon: IconName }> = {
48
76
  query_records: { label: "Searching records", icon: "search" },
@@ -65,23 +93,23 @@ function prettifyToolName(name: string): string {
65
93
  * then the built-in map, then a prettified fallback. Exported so other agent
66
94
  * surfaces (e.g. `AgentProgress`) render the same labels. */
67
95
  export function resolveToolMeta(
68
- toolName: string,
69
- labelForTool?: (toolName: string) => string | undefined,
96
+ call: AgentToolCall,
97
+ labelForCall?: (call: AgentToolCall) => string | undefined,
70
98
  ): { label: string; icon: IconName } {
71
99
  const def =
72
- TOOL_META[toolName] ??
73
- (toolName.startsWith("generate_") && toolName.endsWith("_from_template")
100
+ TOOL_META[call.toolName] ??
101
+ (call.toolName.startsWith("generate_") && call.toolName.endsWith("_from_template")
74
102
  ? { label: "Generating document", icon: "file-text" as IconName }
75
103
  : undefined);
76
104
  return {
77
- label: labelForTool?.(toolName) ?? def?.label ?? prettifyToolName(toolName),
105
+ label: labelForCall?.(call) ?? def?.label ?? prettifyToolName(call.toolName),
78
106
  icon: def?.icon ?? "list",
79
107
  };
80
108
  }
81
109
 
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;
110
+ /** 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;
85
113
  }
86
114
 
87
115
  const INK = colors.zinc[700];
@@ -96,7 +124,8 @@ const INK = colors.zinc[700];
96
124
  * Pair with `Composer` + `ChangeReview`.
97
125
  */
98
126
  export function AgentRun(props: AgentRunProps) {
99
- const { parts, labelForTool, stepsLabel = (n) => `${n} steps`, accessibilityLabel } = props;
127
+ const { parts, labelForCall, renderToolOutput, onRetry, stepsLabel = (n) => `${n} steps`, accessibilityLabel } = props;
128
+ const locale = useLoticsLocale();
100
129
  const segments = toSegments(parts);
101
130
  const state = props.state ?? (anyRunning(segments) ? "streaming" : "done");
102
131
  const streaming = state === "streaming";
@@ -125,23 +154,32 @@ export function AgentRun(props: AgentRunProps) {
125
154
  active={streaming && i === lastIndex}
126
155
  expanded={!!expanded[seg.id]}
127
156
  onToggle={() => toggle(seg.id)}
128
- labelForTool={labelForTool}
157
+ labelForCall={labelForCall}
158
+ renderToolOutput={renderToolOutput}
129
159
  stepsLabel={stepsLabel}
130
160
  />
131
161
  );
132
162
  })}
133
163
  {/* 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. */}
164
+ outside `parts` (the stream failed), so the caller passes it explicitly.
165
+ A retry action rides UNDER it when the caller can restart the run. */}
135
166
  {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>
167
+ <View style={{ gap: 8 }}>
168
+ <View style={styles.row}>
169
+ <View style={styles.dotCol}>
170
+ <Icon name="circle-alert" size={17} color={colors.red[500]} />
171
+ </View>
172
+ <View style={styles.rowBody}>
173
+ <Text size="sm" color="danger">
174
+ {props.error}
175
+ </Text>
176
+ </View>
144
177
  </View>
178
+ {onRetry ? (
179
+ <View style={styles.retry}>
180
+ <Button title={locale.agentRun.retry} color="secondary" icon="rotate-ccw" alignSelf="flex-start" onPress={onRetry} />
181
+ </View>
182
+ ) : null}
145
183
  </View>
146
184
  ) : null}
147
185
  </View>
@@ -179,24 +217,37 @@ function ActivityRow(props: {
179
217
  return <View style={styles.row}>{inner}</View>;
180
218
  }
181
219
 
182
- function StepBody({ label }: { label: string }) {
183
- return <Text size="sm">{label}</Text>;
220
+ // The step label, plus a muted {awaiting}" annotation when the call is parked
221
+ // on a human decision (its amber dot already signals the state; the word names it).
222
+ function StepBody({ label, awaiting }: { label: string; awaiting?: string }) {
223
+ return (
224
+ <Text size="sm">
225
+ {label}
226
+ {awaiting ? (
227
+ <Text size="sm" color="muted">
228
+ {" · " + awaiting}
229
+ </Text>
230
+ ) : null}
231
+ </Text>
232
+ );
184
233
  }
185
234
 
186
235
  // The on-demand reveal for a tool step: Input / Output / Error panels auto-built
187
236
  // 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 {
237
+ // shown here, on demand. Panel titles come from the locale. A caller's
238
+ // `renderToolOutput` replaces the OUTPUT panel (input + error stay auto-built).
239
+ function stepDetail(s: AgentStep, labels: LoticsLocale["agentRun"], renderToolOutput?: AgentRunProps["renderToolOutput"]): ReactNode {
190
240
  const hasInput = s.input !== undefined;
191
241
  const hasOutput = s.output !== undefined;
192
242
  if (!hasInput && !hasOutput && !s.errorText) return null;
243
+ const customOutput = hasOutput && renderToolOutput ? renderToolOutput({ toolName: s.toolName, input: s.input, output: s.output }) : undefined;
193
244
  return (
194
245
  <View style={{ gap: 8 }}>
195
246
  {hasInput ? <JsonPanel title={labels.input} value={stringifyData(s.input)} /> : null}
196
247
  {s.errorText ? (
197
248
  <JsonPanel title={labels.error} value={s.errorText} />
198
249
  ) : hasOutput ? (
199
- <JsonPanel title={labels.output} value={stringifyData(s.output)} />
250
+ customOutput !== undefined ? customOutput : <JsonPanel title={labels.output} value={stringifyData(s.output)} />
200
251
  ) : null}
201
252
  </View>
202
253
  );
@@ -205,24 +256,28 @@ function stepDetail(s: AgentStep, labels: LoticsLocale["agentRun"]): ReactNode {
205
256
  // A settled step row. When the step carries I/O (or an error) it EXPANDS IN PLACE on
206
257
  // press — the panels roll out under the row (same disclosure pattern as "Thinking"),
207
258
  // 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 }) {
259
+ // An `awaiting` call wears the amber dot + an "awaiting" annotation, like an error.
260
+ function StepRow({ s, label, renderToolOutput }: { s: AgentStep; label: string; renderToolOutput?: AgentRunProps["renderToolOutput"] }) {
209
261
  const locale = useLoticsLocale();
210
262
  const [open, setOpen] = useState(false);
211
- const detail = stepDetail(s, locale.agentRun);
212
- const marker: StepStatus = s.status === "error" ? "warning" : "done";
213
- if (!detail) {
263
+ // Pressability is decided by a cheap carried-I/O check; the detail (and any
264
+ // caller `renderToolOutput`) is built only once the row is actually opened.
265
+ const hasDetail = s.input !== undefined || s.output !== undefined || !!s.errorText;
266
+ const marker: StepStatus = s.status === "error" || s.status === "awaiting" ? "warning" : "done";
267
+ const awaiting = s.status === "awaiting" ? locale.agentRun.awaiting : undefined;
268
+ if (!hasDetail) {
214
269
  return (
215
270
  <ActivityRow markerStatus={marker}>
216
- <StepBody label={label} />
271
+ <StepBody label={label} awaiting={awaiting} />
217
272
  </ActivityRow>
218
273
  );
219
274
  }
220
275
  return (
221
276
  <View>
222
277
  <ActivityRow markerStatus={marker} onPress={() => setOpen((o) => !o)} accessibilityLabel={label} trailing={chevron(open ? "up" : "down")}>
223
- <StepBody label={label} />
278
+ <StepBody label={label} awaiting={awaiting} />
224
279
  </ActivityRow>
225
- {open ? <View style={styles.rowDetail}>{detail}</View> : null}
280
+ {open ? <View style={styles.rowDetail}>{stepDetail(s, locale.agentRun, renderToolOutput)}</View> : null}
226
281
  </View>
227
282
  );
228
283
  }
@@ -274,17 +329,29 @@ function ToolGroup(props: {
274
329
  active: boolean;
275
330
  expanded: boolean;
276
331
  onToggle: () => void;
277
- labelForTool?: (toolName: string) => string | undefined;
332
+ labelForCall?: (call: AgentToolCall) => string | undefined;
333
+ renderToolOutput?: AgentRunProps["renderToolOutput"];
278
334
  stepsLabel: (n: number) => string;
279
335
  }) {
280
- const { steps, active, expanded, onToggle, labelForTool, stepsLabel } = props;
281
- const resolve = (s: AgentStep) => stepLabel(s, labelForTool);
336
+ const { steps, active, expanded, onToggle, labelForCall, renderToolOutput, stepsLabel } = props;
337
+ const locale = useLoticsLocale();
338
+ const resolve = (s: AgentStep) => stepLabel(s, labelForCall);
282
339
 
283
340
  // ACTIVE — one pulsing row whose label swaps in place as each call fires (the
284
341
  // label is keyed by the current step's id, so a new call rises + fades into the
285
- // SAME row instead of stacking a new dot).
342
+ // SAME row instead of stacking a new dot). BUT a call parked on a human decision
343
+ // (`awaiting`) is NOT working — it settles to the amber, non-pulsing awaiting row.
286
344
  if (active) {
287
345
  const current = steps[steps.length - 1];
346
+ if (current.status === "awaiting") {
347
+ return (
348
+ <View style={styles.group}>
349
+ <ActivityRow markerStatus="warning">
350
+ <StepBody label={resolve(current)} awaiting={locale.agentRun.awaiting} />
351
+ </ActivityRow>
352
+ </View>
353
+ );
354
+ }
288
355
  return (
289
356
  <View style={styles.group}>
290
357
  <ActivityRow markerStatus="current" live>
@@ -297,6 +364,7 @@ function ToolGroup(props: {
297
364
  }
298
365
 
299
366
  const errored = steps.some((s) => s.status === "error");
367
+ const awaiting = steps.some((s) => s.status === "awaiting");
300
368
  const final = steps[steps.length - 1];
301
369
  const expandable = steps.length > 1;
302
370
 
@@ -304,7 +372,7 @@ function ToolGroup(props: {
304
372
  if (!expandable) {
305
373
  return (
306
374
  <View style={styles.group}>
307
- <StepRow s={final} label={resolve(final)} />
375
+ <StepRow s={final} label={resolve(final)} renderToolOutput={renderToolOutput} />
308
376
  </View>
309
377
  );
310
378
  }
@@ -317,7 +385,7 @@ function ToolGroup(props: {
317
385
  return (
318
386
  <View style={styles.group}>
319
387
  <ActivityRow
320
- markerStatus={errored ? "warning" : "complete"}
388
+ markerStatus={errored || awaiting ? "warning" : "complete"}
321
389
  onPress={onToggle}
322
390
  accessibilityLabel={`${resolve(final)} — ${stepsLabel(steps.length)}`}
323
391
  trailing={chevron(expanded ? "up" : "down")}
@@ -327,7 +395,7 @@ function ToolGroup(props: {
327
395
  {expanded
328
396
  ? steps.map((s) => (
329
397
  <AnimationFadeIn key={s.id} translateY={4}>
330
- <StepRow s={s} label={resolve(s)} />
398
+ <StepRow s={s} label={resolve(s)} renderToolOutput={renderToolOutput} />
331
399
  </AnimationFadeIn>
332
400
  ))
333
401
  : null}
@@ -360,4 +428,7 @@ const styles = StyleSheet.create({
360
428
  reasoning: { paddingLeft: 28, paddingBottom: 4 },
361
429
  // A step's expanded I/O panels — same left edge as the reasoning body.
362
430
  rowDetail: { paddingLeft: 28, paddingBottom: 6, paddingTop: 2 },
431
+ // The retry action under the terminal error — aligned to the message body
432
+ // (past the dot column + its gap), so it reads as a response to that row.
433
+ retry: { paddingLeft: 28 },
363
434
  });
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { toSegments, anyRunning, lastRunningStep, type AgentUIPart, type AgentSegment, type AgentStep } from "./agent_transform";
3
+
4
+ // RN-free logic test for the parts → timeline fold. The renderer (agent_run.tsx)
5
+ // is verified visually in the gallery; the mapping — ai's 7-state tool machine to
6
+ // the feed's 4 states, plus grouping — is unit-tested here. Fixtures are the ai-sdk
7
+ // `dynamic-tool` part shape (contextually typed as `AgentUIPart`, so each concrete
8
+ // `state` discriminates to the right member — no casts).
9
+
10
+ function groups(parts: AgentUIPart[]): Extract<AgentSegment, { kind: "group" }>[] {
11
+ return toSegments(parts).filter((s): s is Extract<AgentSegment, { kind: "group" }> => s.kind === "group");
12
+ }
13
+
14
+ function onlyStep(parts: AgentUIPart[]): AgentStep {
15
+ const [group, ...rest] = groups(parts);
16
+ expect(rest).toHaveLength(0);
17
+ expect(group.steps).toHaveLength(1);
18
+ return group.steps[0];
19
+ }
20
+
21
+ describe("agent_transform — tool state mapping", () => {
22
+ it("input-streaming → running", () => {
23
+ expect(onlyStep([{ type: "dynamic-tool", toolName: "update_records", toolCallId: "t", state: "input-streaming", input: {} }]).status).toBe("running");
24
+ });
25
+
26
+ it("input-available → running", () => {
27
+ expect(onlyStep([{ type: "dynamic-tool", toolName: "update_records", toolCallId: "t", state: "input-available", input: {} }]).status).toBe("running");
28
+ });
29
+
30
+ it("approval-requested → awaiting (parked on a human decision)", () => {
31
+ const step = onlyStep([{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "t", state: "approval-requested", input: { table: "Containers" }, approval: { id: "a1" } }]);
32
+ expect(step.status).toBe("awaiting");
33
+ // The input still rides along for the on-demand peek.
34
+ expect(step.input).toEqual({ table: "Containers" });
35
+ });
36
+
37
+ it("approval-responded → running (the decision is in, work resumes)", () => {
38
+ expect(onlyStep([{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "t", state: "approval-responded", input: {}, approval: { id: "a1", approved: true } }]).status).toBe("running");
39
+ });
40
+
41
+ it("output-available → done, carrying the output", () => {
42
+ const step = onlyStep([{ type: "dynamic-tool", toolName: "query_records", toolCallId: "t", state: "output-available", input: { q: 1 }, output: { rows: 3 } }]);
43
+ expect(step.status).toBe("done");
44
+ expect(step.output).toEqual({ rows: 3 });
45
+ });
46
+
47
+ it("output-error → error, carrying errorText", () => {
48
+ const step = onlyStep([{ type: "dynamic-tool", toolName: "query_records", toolCallId: "t", state: "output-error", input: {}, errorText: "Unknown field" }]);
49
+ expect(step.status).toBe("error");
50
+ expect(step.errorText).toBe("Unknown field");
51
+ });
52
+
53
+ it("output-denied → error, with the denial reason as the error text", () => {
54
+ const step = onlyStep([{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "t", state: "output-denied", input: {}, approval: { id: "a1", approved: false, reason: "Delete not permitted" } }]);
55
+ expect(step.status).toBe("error");
56
+ expect(step.errorText).toBe("Delete not permitted");
57
+ });
58
+
59
+ it("output-denied without a reason → error with no error text", () => {
60
+ const step = onlyStep([{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "t", state: "output-denied", input: {}, approval: { id: "a1", approved: false } }]);
61
+ expect(step.status).toBe("error");
62
+ expect(step.errorText).toBeUndefined();
63
+ });
64
+ });
65
+
66
+ describe("agent_transform — streaming signals", () => {
67
+ it("anyRunning is true while a call runs, false once every call has settled", () => {
68
+ const running = toSegments([{ type: "dynamic-tool", toolName: "query_records", toolCallId: "t", state: "input-available", input: {} }]);
69
+ expect(anyRunning(running)).toBe(true);
70
+
71
+ const settled = toSegments([{ type: "dynamic-tool", toolName: "query_records", toolCallId: "t", state: "output-available", input: {}, output: {} }]);
72
+ expect(anyRunning(settled)).toBe(false);
73
+ });
74
+
75
+ it("an awaiting call is NOT running — it's parked, so the feed never pulses it", () => {
76
+ const parked = toSegments([{ type: "dynamic-tool", toolName: "delete_records", toolCallId: "t", state: "approval-requested", input: {}, approval: { id: "a1" } }]);
77
+ expect(anyRunning(parked)).toBe(false);
78
+ expect(lastRunningStep(parked)).toBeUndefined();
79
+ });
80
+ });
81
+
82
+ describe("agent_transform — grouping", () => {
83
+ it("folds consecutive tool parts into ONE group, split by prose", () => {
84
+ const parts: AgentUIPart[] = [
85
+ { type: "text", text: "First" },
86
+ { type: "dynamic-tool", toolName: "query_records", toolCallId: "a", state: "output-available", input: {}, output: {} },
87
+ { type: "dynamic-tool", toolName: "query_records", toolCallId: "b", state: "output-available", input: {}, output: {} },
88
+ { type: "text", text: "Then" },
89
+ { type: "dynamic-tool", toolName: "delete_records", toolCallId: "c", state: "approval-requested", input: {}, approval: { id: "a1" } },
90
+ ];
91
+ expect(toSegments(parts).map((s) => s.kind)).toEqual(["text", "group", "text", "group"]);
92
+ const [first, second] = groups(parts);
93
+ expect(first.steps.map((s) => s.id)).toEqual(["a", "b"]);
94
+ expect(second.steps.map((s) => s.status)).toEqual(["awaiting"]);
95
+ });
96
+ });
@@ -10,8 +10,11 @@ import type { UIMessagePart, UIDataTypes, UITools } from "ai";
10
10
  /** An ai-sdk message part, tool-set-agnostic (we only read `type` + a few fields). */
11
11
  export type AgentUIPart = UIMessagePart<UIDataTypes, UITools>;
12
12
 
13
- /** A tool step's settle state, collapsed from ai's 7-state tool machine. */
14
- export type AgentStepStatus = "running" | "done" | "error";
13
+ /** A tool step's settle state, folded from ai's 7-state tool machine.
14
+ * `awaiting` = the call is parked on a human decision (`approval-requested`) —
15
+ * first-class, distinct from `running` (working) so the feed never pulses a
16
+ * parked call. */
17
+ export type AgentStepStatus = "running" | "awaiting" | "done" | "error";
15
18
 
16
19
  /** One tool call, reduced to what the feed shows. `toolName` is the RAW name — the
17
20
  * renderer maps it to a human label + icon (`resolveToolMeta`). */
@@ -38,11 +41,15 @@ function isToolPart(part: AgentUIPart): part is Extract<AgentUIPart, { toolCallI
38
41
  return part.type === "dynamic-tool" || part.type.startsWith("tool-");
39
42
  }
40
43
 
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.
44
+ // ai's 7-state tool machine → the feed's 4 states. `approval-requested` is
45
+ // first-class `awaiting` (the call sits parked on a human decision);
46
+ // `approval-responded` reads `running` until the resolved output arrives —
47
+ // `output-available` on approve, `output-denied` (an error carrying the denial
48
+ // reason) on deny. `input-streaming`/`input-available` are `running`.
43
49
  function toStatus(state: string): AgentStepStatus {
44
50
  if (state === "output-available") return "done";
45
51
  if (state === "output-error" || state === "output-denied") return "error";
52
+ if (state === "approval-requested") return "awaiting";
46
53
  return "running";
47
54
  }
48
55
 
@@ -53,7 +60,12 @@ function toStep(part: Extract<AgentUIPart, { toolCallId: string }>): AgentStep {
53
60
  status: toStatus(part.state),
54
61
  input: part.input,
55
62
  output: part.state === "output-available" ? part.output : undefined,
56
- errorText: part.state === "output-error" ? part.errorText : undefined,
63
+ errorText:
64
+ part.state === "output-error"
65
+ ? part.errorText
66
+ : part.state === "output-denied"
67
+ ? part.approval.reason
68
+ : undefined,
57
69
  };
58
70
  }
59
71
 
@@ -0,0 +1,70 @@
1
+ import { type ReactNode } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { colors } from "./colors";
4
+ import { Text } from "./text";
5
+ import { Button } from "./button";
6
+ import { Icon } from "./icon";
7
+ import { useLoticsLocale } from "./locale";
8
+
9
+ export interface ApprovalPromptProps {
10
+ /** Confirm the gated action — the agent proceeds. */
11
+ onApprove: () => void;
12
+ /** Reject the gated action — the agent does not proceed. */
13
+ onDeny: () => void;
14
+ /** Per-instance override of the prompt line (e.g. a browser-specific
15
+ * phrasing); defaults to the `approvalPrompt` locale slice's `message`. */
16
+ message?: string;
17
+ /** Optional detail rendered between the message and the actions — e.g. a
18
+ * summary of the input the agent is asking to run. */
19
+ children?: ReactNode;
20
+ }
21
+
22
+ /**
23
+ * The prompt that ANSWERS an agent's approval-gated action — the interactive
24
+ * counterpart to `AgentRun`'s read-only amber `awaiting` row. A warning icon +
25
+ * message on a bordered card, with Deny (secondary) + Approve (primary)
26
+ * right-aligned; the `children` slot renders an input summary between the two.
27
+ * Every string resolves prop → the `approvalPrompt` locale slice → English.
28
+ * Typically slotted where the composer sits while the run is parked.
29
+ */
30
+ export function ApprovalPrompt(props: ApprovalPromptProps) {
31
+ const { onApprove, onDeny, message, children } = props;
32
+ const labels = useLoticsLocale().approvalPrompt;
33
+
34
+ return (
35
+ <View style={styles.container}>
36
+ <View style={styles.content}>
37
+ <Icon name="shield-alert" size={20} color={colors.orange[600]} />
38
+ <Text size="sm" style={{ flex: 1 }}>
39
+ {message ?? labels.message}
40
+ </Text>
41
+ </View>
42
+ {children}
43
+ <View style={styles.actions}>
44
+ <Button title={labels.deny} color="secondary" onPress={onDeny} />
45
+ <Button title={labels.approve} color="primary" onPress={onApprove} />
46
+ </View>
47
+ </View>
48
+ );
49
+ }
50
+
51
+ const styles = StyleSheet.create({
52
+ container: {
53
+ padding: 12,
54
+ borderRadius: 16,
55
+ borderWidth: 2,
56
+ borderColor: colors.border,
57
+ backgroundColor: colors.background,
58
+ gap: 12,
59
+ },
60
+ content: {
61
+ flexDirection: "row",
62
+ alignItems: "center",
63
+ gap: 8,
64
+ },
65
+ actions: {
66
+ flexDirection: "row",
67
+ justifyContent: "flex-end",
68
+ gap: 8,
69
+ },
70
+ });
package/src/locale.tsx CHANGED
@@ -129,10 +129,19 @@ export interface LoticsLocale {
129
129
  scrollToBottom: { tooltip: string };
130
130
  /** `TextInputField`: the clear-button tooltip. */
131
131
  textInputField: { clear: string };
132
- /** `AgentRun`: the reasoning disclosure's label (settled / streaming) and the
133
- * auto-built tool peek's Input / Error / Output panel titles. (Tool-step labels
134
- * and the "{n} steps" suffix stay prop-localized `labelForTool` / `stepsLabel`.) */
135
- agentRun: { thinking: string; thinkingStreaming: string; input: string; error: string; output: string };
132
+ /** `AgentRun`: the reasoning disclosure's label (settled / streaming), the
133
+ * auto-built tool peek's Input / Error / Output panel titles, the `awaiting`
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: { thinking: string; thinkingStreaming: string; input: string; error: string; output: string; awaiting: string; retry: string };
138
+ /** `ApprovalPrompt`: the default prompt line (overridable per instance) and
139
+ * the Approve / Deny button labels — the surface that ANSWERS `AgentRun`'s
140
+ * read-only `awaiting` row (approve/deny = the ai-sdk approval vocabulary). */
141
+ approvalPrompt: { message: string; approve: string; deny: string };
142
+ /** `MessageActions`: the copy tooltip + its flipped "copied" confirmation, the
143
+ * regenerate and edit tooltips, and the branch pager's prev/next names. */
144
+ messageActions: { copy: string; copied: string; regenerate: string; edit: string; previousVersion: string; nextVersion: string };
136
145
  }
137
146
 
138
147
  /** The platform default — English. Every component's hardcoded default lives
@@ -228,7 +237,9 @@ export const en: LoticsLocale = {
228
237
  matrix: { total: "Total", less: "Less", more: "More" },
229
238
  scrollToBottom: { tooltip: "Scroll to bottom" },
230
239
  textInputField: { clear: "Clear" },
231
- agentRun: { thinking: "Thinking", thinkingStreaming: "Thinking…", input: "Input", error: "Error", output: "Output" },
240
+ agentRun: { thinking: "Thinking", thinkingStreaming: "Thinking…", input: "Input", error: "Error", output: "Output", awaiting: "Awaiting", retry: "Retry" },
241
+ approvalPrompt: { message: "The assistant wants to perform an action that needs your approval.", approve: "Approve", deny: "Deny" },
242
+ messageActions: { copy: "Copy", copied: "Copied", regenerate: "Regenerate", edit: "Edit", previousVersion: "Previous version", nextVersion: "Next version" },
232
243
  };
233
244
 
234
245
  /** Vietnamese. Maintained once here so every app (and the frontend) shares one
@@ -324,7 +335,9 @@ export const vi: LoticsLocale = {
324
335
  matrix: { total: "Tổng", less: "Ít", more: "Nhiều" },
325
336
  scrollToBottom: { tooltip: "Cuộn xuống cuối" },
326
337
  textInputField: { clear: "Xóa" },
327
- agentRun: { thinking: "Suy nghĩ", thinkingStreaming: "Đang suy nghĩ…", input: "Đầu vào", error: "Lỗi", output: "Kết quả" },
338
+ agentRun: { thinking: "Suy nghĩ", thinkingStreaming: "Đang suy nghĩ…", input: "Đầu vào", error: "Lỗi", output: "Kết quả", awaiting: "Chờ duyệt", retry: "Thử lại" },
339
+ approvalPrompt: { message: "Trợ lý muốn thực hiện thao tác cần bạn duyệt.", approve: "Cho phép", deny: "Từ chối" },
340
+ 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" },
328
341
  };
329
342
 
330
343
  const LoticsLocaleContext = createContext<LoticsLocale>(en);
@@ -0,0 +1,111 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { StyleSheet, View } from "react-native";
3
+ import { Text } from "./text";
4
+ import { IconButton } from "./icon_button";
5
+ import { useLoticsLocale } from "./locale";
6
+
7
+ /** The branch/version pager slot — the previous/next controls over a message's
8
+ * sibling versions. Rendered only when `total > 1`. */
9
+ export interface MessageActionsBranch {
10
+ current: number;
11
+ total: number;
12
+ onPrevious: () => void;
13
+ onNext: () => void;
14
+ disabled?: boolean;
15
+ }
16
+
17
+ export interface MessageActionsProps {
18
+ /** Copy the message. The bar OWNS the copied feedback — the icon flips to a
19
+ * check and the tooltip flips to "Copied" for ~2s (the timer is cleared on
20
+ * unmount). The handler only performs the clipboard write — it reads the
21
+ * message content the bar cannot see. */
22
+ onCopy?: () => void | Promise<void>;
23
+ /** Regenerate the (assistant) message. */
24
+ onRegenerate?: () => void;
25
+ /** Edit the (user) message. */
26
+ onEdit?: () => void;
27
+ /** The sibling-version pager, absorbed as an internal part of the bar. */
28
+ branch?: MessageActionsBranch;
29
+ }
30
+
31
+ /**
32
+ * The single shared per-message action bar — the branch/version pager, copy
33
+ * (with the owned copied-feedback contract), regenerate, and edit. Each action
34
+ * renders ONLY when its handler is provided, so an assistant bar (copy +
35
+ * regenerate) and a user bar (copy + edit) are the same component with a
36
+ * different handler set; the pager renders when `branch.total > 1`. The bar
37
+ * hugs its content, so the parent controls the row's side (left for an
38
+ * assistant turn, right for a user bubble). Strings resolve through the
39
+ * `messageActions` locale slice.
40
+ */
41
+ export function MessageActions(props: MessageActionsProps) {
42
+ const { onCopy, onRegenerate, onEdit, branch } = props;
43
+ const labels = useLoticsLocale().messageActions;
44
+ const [copied, setCopied] = useState(false);
45
+ const timer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
46
+
47
+ // The copied flag flips back after a beat; the timer is cleared on unmount so
48
+ // a bar that disappears (last message loses its actions) never sets state on
49
+ // an unmounted tree.
50
+ useEffect(() => {
51
+ return () => {
52
+ if (timer.current) clearTimeout(timer.current);
53
+ };
54
+ }, []);
55
+
56
+ const handleCopy = useCallback(async () => {
57
+ await onCopy?.();
58
+ setCopied(true);
59
+ if (timer.current) clearTimeout(timer.current);
60
+ timer.current = setTimeout(() => setCopied(false), 2000);
61
+ }, [onCopy]);
62
+
63
+ return (
64
+ <View style={styles.bar}>
65
+ {branch ? <BranchPager branch={branch} previousLabel={labels.previousVersion} nextLabel={labels.nextVersion} /> : null}
66
+ {onCopy ? (
67
+ <IconButton
68
+ testID="copy-button"
69
+ onPress={handleCopy}
70
+ icon={copied ? "check" : "copy"}
71
+ tooltip={copied ? labels.copied : labels.copy}
72
+ />
73
+ ) : null}
74
+ {onRegenerate ? (
75
+ <IconButton testID="regenerate-button" onPress={onRegenerate} icon="refresh-cw" tooltip={labels.regenerate} />
76
+ ) : null}
77
+ {onEdit ? (
78
+ <IconButton testID="edit-button" onPress={onEdit} icon="pencil" tooltip={labels.edit} />
79
+ ) : null}
80
+ </View>
81
+ );
82
+ }
83
+
84
+ // The sibling-version pager: [‹] {current} / {total} [›]. A single version has
85
+ // nothing to page, so it renders nothing.
86
+ function BranchPager({ branch, previousLabel, nextLabel }: { branch: MessageActionsBranch; previousLabel: string; nextLabel: string }) {
87
+ const { current, total, onPrevious, onNext, disabled } = branch;
88
+ if (total <= 1) return null;
89
+ return (
90
+ <View testID="branch-pagination" style={styles.branch}>
91
+ <IconButton testID="branch-prev" icon="chevron-left" tooltip={previousLabel} onPress={onPrevious} disabled={disabled || current <= 1} />
92
+ <Text size="xs" color="muted">
93
+ {current} / {total}
94
+ </Text>
95
+ <IconButton testID="branch-next" icon="chevron-right" tooltip={nextLabel} onPress={onNext} disabled={disabled || current >= total} />
96
+ </View>
97
+ );
98
+ }
99
+
100
+ const styles = StyleSheet.create({
101
+ bar: {
102
+ flexDirection: "row",
103
+ alignItems: "center",
104
+ gap: 4,
105
+ },
106
+ branch: {
107
+ flexDirection: "row",
108
+ alignItems: "center",
109
+ gap: 2,
110
+ },
111
+ });