@lotics/ui 14.3.0 → 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 +2 -27
- package/MIGRATION.md +44 -0
- package/docs/ai_patterns.md +41 -17
- package/docs/catalog.md +37 -10
- package/examples/tpl_item_list.tsx +17 -1
- package/package.json +6 -6
- package/src/agent_progress.tsx +11 -7
- package/src/agent_run.tsx +114 -43
- package/src/agent_transform.test.ts +96 -0
- package/src/agent_transform.ts +17 -5
- package/src/approval_prompt.tsx +70 -0
- package/src/locale.tsx +19 -6
- package/src/message_actions.tsx +111 -0
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.
|
package/docs/ai_patterns.md
CHANGED
|
@@ -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
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
|
@@ -149,24 +158,39 @@ list is already inverted and doesn't need the wrapper.
|
|
|
149
158
|
|
|
150
159
|
Fed natively by `@lotics/app-sdk`'s `useAgentRun().parts` (reasoning + per-tool I/O + state come
|
|
151
160
|
for free — no hand-assembly): `<AgentRun parts={run.parts} state={…} error={run.error} />` — see
|
|
152
|
-
[the SDK doc](../../app-sdk/docs/ai.md) for the hook.
|
|
161
|
+
[the SDK doc](../../app-sdk/docs/ai.md) for the hook. The `ai` package is a regular dependency of
|
|
162
|
+
`@lotics/ui` — its part TYPES resolve transitively in any consumer's typecheck with nothing to
|
|
163
|
+
install; the imports are type-only (purity-enforced), so no `ai` runtime ever enters a bundle.
|
|
153
164
|
**Reasoning only shows when the model produces it** — the declared model must support thinking
|
|
154
165
|
(Sonnet / Opus, not Haiku); the renderer shows every reasoning part it's handed.
|
|
155
166
|
|
|
156
|
-
**
|
|
157
|
-
|
|
158
|
-
|
|
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.
|
|
159
183
|
|
|
160
184
|
### `AgentProgress` — the floating pill
|
|
161
185
|
|
|
162
186
|
On a canvas/composer app reach for `AgentProgress` (`@lotics/ui/agent_progress`) — `AgentRun`
|
|
163
187
|
collapsed into a floating pill (an animated `WaveAvatar` + the current step's label) that EXPANDS
|
|
164
|
-
on press to the full feed in a capped scroll panel. It takes the same `
|
|
165
|
-
`
|
|
166
|
-
step's label, else "Working…" / "Done" /
|
|
167
|
-
string, so it no longer tracks the running
|
|
168
|
-
`Composer`'s compact geometry, so the composer morphs
|
|
169
|
-
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.
|
|
170
194
|
|
|
171
195
|
**Limitation:** the pill's expand/collapse accessibility labels ("Show the agent's steps" /
|
|
172
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
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
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
|
|
@@ -1111,6 +1111,10 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
|
|
|
1111
1111
|
const [fileNames, setFileNames] = useState<string[]>([]);
|
|
1112
1112
|
const [variant, setVariant] = useState<ManualVariant>("export");
|
|
1113
1113
|
const [grouping, setGrouping] = useState<"customer" | "order">("customer");
|
|
1114
|
+
// Q2's answer is load-bearing too: it decides how the unknown customer lands
|
|
1115
|
+
// on the drafted records (create + link, leave unlinked, or the user's own
|
|
1116
|
+
// instruction via the custom slot).
|
|
1117
|
+
const [customerPlan, setCustomerPlan] = useState("New customer created and linked");
|
|
1114
1118
|
// review state — per-card verdicts + the one editable value (the fee)
|
|
1115
1119
|
const [cardStatus, setCardStatus] = useState<Record<string, ChangeStatus>>({});
|
|
1116
1120
|
const [fees, setFees] = useState<Record<string, string>>({});
|
|
@@ -1151,6 +1155,7 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
|
|
|
1151
1155
|
setFileNames([]);
|
|
1152
1156
|
setCardStatus({});
|
|
1153
1157
|
setFees({});
|
|
1158
|
+
setCustomerPlan("New customer created and linked");
|
|
1154
1159
|
setKhach("");
|
|
1155
1160
|
setDienThoai("");
|
|
1156
1161
|
setPhi(null);
|
|
@@ -1233,6 +1238,14 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
|
|
|
1233
1238
|
onCancel={() => setPhase("intake")}
|
|
1234
1239
|
onSubmit={(answers: ClarifyWizardAnswer[]) => {
|
|
1235
1240
|
setGrouping(answers[0]?.value === "order" ? "order" : "customer");
|
|
1241
|
+
const plan = answers[1];
|
|
1242
|
+
setCustomerPlan(
|
|
1243
|
+
plan?.custom
|
|
1244
|
+
? plan.value
|
|
1245
|
+
: plan?.value === "skip"
|
|
1246
|
+
? "Left unassigned — link a customer later"
|
|
1247
|
+
: "New customer created and linked",
|
|
1248
|
+
);
|
|
1236
1249
|
setPhase("running");
|
|
1237
1250
|
}}
|
|
1238
1251
|
/>
|
|
@@ -1256,6 +1269,9 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
|
|
|
1256
1269
|
onUndo={() => setCardStatus((s) => ({ ...s, [p.id]: "pending" }))}
|
|
1257
1270
|
>
|
|
1258
1271
|
<ChangeField label="Customer" value={p.khach} summary={p.khach} />
|
|
1272
|
+
{p.khach === "Blue Harbor Foods" ? (
|
|
1273
|
+
<ChangeField label="Customer link" value={customerPlan} summary={customerPlan} />
|
|
1274
|
+
) : null}
|
|
1259
1275
|
{p.dienThoai ? <ChangeField label="Phone" value={p.dienThoai} summary={p.dienThoai} /> : null}
|
|
1260
1276
|
<ChangeField label="Service fee" value={fees[p.id] ?? String(p.phi)} summary={formatMoney(Number(fees[p.id] ?? p.phi) || 0)}>
|
|
1261
1277
|
<ChangeValueInput value={fees[p.id] ?? String(p.phi)} onChangeText={(v) => setFees((s) => ({ ...s, [p.id]: v }))} accessibilityLabel={`Service fee for ${p.khach}`} />
|
|
@@ -1316,7 +1332,7 @@ function EnterDataDialog({ onCreate, onCreateMany }: {
|
|
|
1316
1332
|
</DialogFooter>
|
|
1317
1333
|
) : phase === "review" ? (
|
|
1318
1334
|
<DialogFooter>
|
|
1319
|
-
<ChangeReviewActions onDiscard={close} onApply={applyProposals} applyLabel={`Add ${keptCount} ${keptCount === 1 ? "record" : "records"}`} />
|
|
1335
|
+
<ChangeReviewActions onDiscard={close} onApply={applyProposals} applyLabel={keptCount > 0 ? `Add ${keptCount} ${keptCount === 1 ? "record" : "records"}` : "Add records"} />
|
|
1320
1336
|
</DialogFooter>
|
|
1321
1337
|
) : null}
|
|
1322
1338
|
</Dialog>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lotics/ui",
|
|
3
|
-
"version": "
|
|
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": {
|
|
@@ -248,6 +251,7 @@
|
|
|
248
251
|
"dependencies": {
|
|
249
252
|
"@lotics/docx": "^0.1.0",
|
|
250
253
|
"@lotics/xlsx": "^0.1.0",
|
|
254
|
+
"ai": "^7.0.30",
|
|
251
255
|
"mdast-util-from-markdown": "^2.0.3",
|
|
252
256
|
"mdast-util-gfm-footnote": "^2.1.0",
|
|
253
257
|
"mdast-util-gfm-strikethrough": "^2.0.0",
|
|
@@ -266,13 +270,9 @@
|
|
|
266
270
|
"react-dom": "^19.2.0",
|
|
267
271
|
"react-native": ">=0.85.0",
|
|
268
272
|
"react-native-svg": ">=15.0.0",
|
|
269
|
-
"react-native-web": ">=0.20.0"
|
|
270
|
-
"ai": ">=7.0.0"
|
|
273
|
+
"react-native-web": ">=0.20.0"
|
|
271
274
|
},
|
|
272
275
|
"peerDependenciesMeta": {
|
|
273
|
-
"ai": {
|
|
274
|
-
"optional": true
|
|
275
|
-
},
|
|
276
276
|
"expo-image": {
|
|
277
277
|
"optional": true
|
|
278
278
|
},
|
package/src/agent_progress.tsx
CHANGED
|
@@ -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
|
|
22
|
-
*
|
|
23
|
-
|
|
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,
|
|
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,
|
|
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}
|
|
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
|
|
34
|
-
* (`update_records` → "Đang cập nhật dữ liệu"
|
|
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
|
-
|
|
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 `
|
|
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
|
-
|
|
69
|
-
|
|
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:
|
|
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
|
|
83
|
-
function stepLabel(s: AgentStep,
|
|
84
|
-
return resolveToolMeta(s.toolName,
|
|
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,
|
|
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
|
-
|
|
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={
|
|
137
|
-
<View style={styles.
|
|
138
|
-
<
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
<
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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}>{
|
|
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
|
-
|
|
332
|
+
labelForCall?: (call: AgentToolCall) => string | undefined;
|
|
333
|
+
renderToolOutput?: AgentRunProps["renderToolOutput"];
|
|
278
334
|
stepsLabel: (n: number) => string;
|
|
279
335
|
}) {
|
|
280
|
-
const { steps, active, expanded, onToggle,
|
|
281
|
-
const
|
|
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
|
+
});
|
package/src/agent_transform.ts
CHANGED
|
@@ -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,
|
|
14
|
-
|
|
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
|
|
42
|
-
//
|
|
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:
|
|
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)
|
|
133
|
-
* auto-built tool peek's Input / Error / Output panel titles
|
|
134
|
-
*
|
|
135
|
-
|
|
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
|
+
});
|