@lotics/ui 45.10.1 → 46.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/MIGRATION.md +27 -0
- package/docs/ai_patterns.md +78 -10
- package/docs/catalog.md +17 -3
- package/package.json +1 -1
- package/src/agent_progress.tsx +5 -4
- package/src/agent_run.tsx +218 -44
- package/src/agent_run_pane.tsx +5 -0
- package/src/agent_transform.ts +34 -0
- package/src/locale.tsx +3 -7
package/MIGRATION.md
CHANGED
|
@@ -4,6 +4,33 @@ Breaking changes, newest first — normally per major, plus the rare minor that
|
|
|
4
4
|
anyway (recorded under its exact version). The current contract lives in `AGENTS.md` + `docs/`;
|
|
5
5
|
this file exists only to move an app from one release to the next.
|
|
6
6
|
|
|
7
|
+
## 46.0.0
|
|
8
|
+
|
|
9
|
+
**`AgentRun` / `AgentProgress` drop `stepsLabel`, and the `agentRun` locale slice drops `steps`.**
|
|
10
|
+
The run row no longer prints a step count — not on the folded run row, not on a settled
|
|
11
|
+
`ToolGroup` header. The tally was a number a reader could act on in no way: while the run is live
|
|
12
|
+
the label already changes on every call, so it added no motion the row did not have, and once
|
|
13
|
+
settled it sized work nobody had asked to size. The chevron carries "there is more inside".
|
|
14
|
+
|
|
15
|
+
Delete the prop and the pack key; both are now type errors.
|
|
16
|
+
|
|
17
|
+
```diff
|
|
18
|
+
-<AgentRun parts={parts} stepsLabel={(n) => plural(n, { one: "# step", other: "# steps" })} />
|
|
19
|
+
+<AgentRun parts={parts} />
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
```diff
|
|
23
|
+
agentRun: {
|
|
24
|
+
tools: { … },
|
|
25
|
+
- steps: (n) => `${n} bước`,
|
|
26
|
+
},
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
A row's accessible name is now the label alone (`"Searching records"`), where it was
|
|
30
|
+
`"Searching records — 2 steps"` — update any test that queried it by the old string. Two rows can
|
|
31
|
+
now share a name once the work is expanded (the run row and the step it was named for), so prefer
|
|
32
|
+
`getAllByLabelText(...)[0]` over `getByLabelText` there.
|
|
33
|
+
|
|
7
34
|
## 45.9.0
|
|
8
35
|
|
|
9
36
|
**`BrandMark` no longer carries product logos, and `SwitchButton` takes `leading` instead of
|
package/docs/ai_patterns.md
CHANGED
|
@@ -171,17 +171,81 @@ Output panel; `undefined` keeps it. Input + error rendering are untouched.
|
|
|
171
171
|
**Activity grouping.** Consecutive tool parts fold into ONE group. While the agent is mid-tools
|
|
172
172
|
the tail group is a SINGLE pulsing row whose label swaps in place as each call fires (no growing
|
|
173
173
|
stack of dots); once prose resumes the group settles: a single call becomes one done row, and a
|
|
174
|
-
multi-call group becomes a persistent "{final action}
|
|
174
|
+
multi-call group becomes a persistent "{final action}" HEADER (a `complete` terminal
|
|
175
175
|
dot — distinct from the filled `done` step dots; amber `warning` if any call errored) that STAYS
|
|
176
|
-
PUT and rolls the calls out BELOW it on press.
|
|
177
|
-
|
|
176
|
+
PUT and rolls the calls out BELOW it on press.
|
|
177
|
+
|
|
178
|
+
**ONE row for the whole run, and NO prose until it ends.** While the agent works, the entire feed
|
|
179
|
+
is a single row. It names the call in flight — "Searching records" — and says
|
|
180
|
+
**"Thinking…"** whenever the model is writing rather than calling, which is every gap between
|
|
181
|
+
calls. The dot pulses; nothing rolls out unless the reader asks.
|
|
182
|
+
|
|
183
|
+
**No step count.** The row says WHAT is happening and stops there. A tally was a number the reader
|
|
184
|
+
could act on in no way: live, the label already changes on every call, so it added no motion the
|
|
185
|
+
row did not have; settled, it sized work nobody had asked to size. The chevron carries "there is
|
|
186
|
+
more inside".
|
|
187
|
+
|
|
188
|
+
**The agent's between-call text is not shown at all.** It is narration by instruction — a plan the
|
|
189
|
+
prompt tells it to state up front, asides capped at two sentences — and none of it is load-bearing
|
|
190
|
+
in flight. The one message that genuinely needs a response is a bulk-mutation confirmation, and
|
|
191
|
+
there the agent WAITS: the run stops, so that text becomes the trailing text and renders as the
|
|
192
|
+
answer. Showing the rest bought a sentence that appeared, got half-read, and vanished when the
|
|
193
|
+
next call superseded it. The threshold is that the run did SOME work — a reply with no tool calls
|
|
194
|
+
at all is all answer, and hiding *that* behind a row until settle would be the real loss.
|
|
195
|
+
|
|
196
|
+
A reader waiting on an answer is not reading a transcript, and a transcript that scrolls past
|
|
197
|
+
while they wait is still noise — bounding it only makes the noise a fixed size. What they need is
|
|
198
|
+
that something is happening and roughly what; the label and the count carry both, and the press
|
|
199
|
+
carries the rest. That the waits are long (production: **p50 25s, p90 94s**; five-plus tool calls
|
|
200
|
+
p50 52s / p90 144s) is an argument for the label CHANGING, not for showing the feed.
|
|
201
|
+
|
|
202
|
+
The cost, stated plainly: the answer appears at settle rather than streaming. Final responses are
|
|
203
|
+
≤3 sentences by instruction, so what is given up is a second of typing — against never yanking a
|
|
204
|
+
half-read sentence out from under anyone.
|
|
205
|
+
|
|
206
|
+
Grouping alone could never deliver this, because a group breaks on every prose part AND every
|
|
207
|
+
reasoning part. An interleaved-thinking run therefore rebuilt the stack one group at a time — the
|
|
208
|
+
reader watched finished rows pile up while they waited, and was then left with that pile sitting
|
|
209
|
+
above the answer at the answer's weight, every row of it stale.
|
|
210
|
+
|
|
211
|
+
**Expanding is where live and settled differ, and the only place they do.** Opened mid-run the
|
|
212
|
+
timeline is CAPPED and self-pinning (220px, ~5 rows) — someone who opens a run in flight asked to
|
|
213
|
+
see the work, not to hand the page a feed that grows for another minute; `FollowScroll` follows at
|
|
214
|
+
LAYOUT level so each call paints already pinned. Opened once settled it is uncapped: nothing is
|
|
215
|
+
arriving, so there is nothing to cap. Settling never closes what a reader opened; it just stops
|
|
216
|
+
capping it.
|
|
217
|
+
|
|
218
|
+
The split is POSITIONAL (`splitTimeline` in `agent_transform`): what comes after the last tool
|
|
219
|
+
call is the answer, everything before it is the work. Nothing has to decide whether a paragraph
|
|
220
|
+
was a plan or a report — a model emits all of it as `text`, so only position is knowable, and
|
|
221
|
+
while the run is still moving that position is not yet final. Which is why the answer waits for
|
|
222
|
+
settle: at that moment, and only then, the trailing text is known to be the report.
|
|
223
|
+
|
|
224
|
+
The work stays fully open where collapsing would hide something needed: while any call is parked
|
|
225
|
+
`awaiting` a human decision (the approval surface would be asking about work the reader can no
|
|
226
|
+
longer see); when a SETTLED run stopped ON a tool call, leaving no answer to fold behind (mid-run
|
|
227
|
+
that is just "not there yet", not a dead end); when the work is already one row (folding buys
|
|
228
|
+
nothing and costs a second press to reach a step's I/O); and when no tool ran at all (`Thinking`
|
|
229
|
+
is already a collapsed row, and a summary could only name a step that never happened).
|
|
230
|
+
**`collapseProcess={false}`** hands the whole job back to the caller — for a surface that ALREADY
|
|
231
|
+
frames the run (`AgentProgress`'s pill) or one whose subject is a step's own output; such a
|
|
232
|
+
surface owns the `FollowScroll` too, and one on the default must NOT add one or it nests a
|
|
233
|
+
scroller in a scroller. **`summarizeRun(steps)`** renames the row; the default names the current
|
|
234
|
+
or last action and nothing more, because only the HOST knows which of its tools WROTE, and a
|
|
235
|
+
kit-side list of write tools would be silently incomplete the first time one was added —
|
|
236
|
+
reporting a run that changed data as though it had only read. A host that knows should say so
|
|
237
|
+
("Updated 3 records"): that is the one fact worth reading without expanding.
|
|
238
|
+
|
|
239
|
+
An ARTIFACT the run produced — a file, an image, a record diff — belongs in the result zone the
|
|
240
|
+
host renders below the feed, never inside a tool's output panel where the fold puts it two
|
|
241
|
+
presses away.
|
|
178
242
|
|
|
179
243
|
**Tool labels.** A tool part carries the RAW tool name (`update_records` from `tool-update_records`,
|
|
180
244
|
or a `dynamic-tool`'s `toolName`), resolved through the active locale's **`agentRun.tools`** map.
|
|
181
245
|
An optional **`labelForCall`** override still wins, for phrasing a call by what it TARGETS rather
|
|
182
246
|
than by which tool ran. It gets the whole call — `{ toolName, input, state }` (`AgentToolCall`);
|
|
183
247
|
return `undefined` to fall back. Unknown names fall back
|
|
184
|
-
to a prettified form.
|
|
248
|
+
to a prettified form. The tool `state` folds ai's
|
|
185
249
|
7-state lifecycle to **running / awaiting / done / error**: `approval-requested` → `awaiting` (the
|
|
186
250
|
call is parked on a human decision — an amber dot + an "awaiting" annotation, and it NEVER
|
|
187
251
|
pulses); `approval-responded` → running; `output-denied` → error carrying the
|
|
@@ -197,9 +261,13 @@ tinted panel rather than another step row, and it carries `role="alert"` so a sc
|
|
|
197
261
|
action (a secondary `Button`) sits INSIDE that panel so the operator can re-fire the run; omit it
|
|
198
262
|
and the panel carries the message alone.
|
|
199
263
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
the
|
|
264
|
+
**`FollowScroll` — only when you took the job (`collapseProcess={false}`).** A run opened mid-flight
|
|
265
|
+
already caps itself with exactly this, so wrapping the default AGAIN nests a scroller in a
|
|
266
|
+
scroller: the outer never overflows, the inner holds the content, and the wheel lands on whichever
|
|
267
|
+
the pointer happens to be over. Reach for the wrapper when the run is a raw timeline you are
|
|
268
|
+
framing yourself — a dialog, drawer, or fixed-height panel where the feed grows BELOW the fold and
|
|
269
|
+
a plain scroll container doesn't follow. Wrap it then:
|
|
270
|
+
`<FollowScroll style={{ maxHeight: … }}><AgentRun … collapseProcess={false} /></FollowScroll>` — the same
|
|
203
271
|
inverted-list mechanism as a chat message list: the follow happens at
|
|
204
272
|
LAYOUT level (each growth paints already pinned — no scroll-after-paint flash), scrolling up
|
|
205
273
|
releases it, and the feed stays at the newest content when the run settles (`AgentProgress`'s
|
|
@@ -225,9 +293,9 @@ feed**; the starting row IS the placeholder, consistent and localized.
|
|
|
225
293
|
|
|
226
294
|
**Localization.** The feed's fixed chrome — the "Thinking" label, the "Input"/"Output"/"Error"
|
|
227
295
|
panel titles, the "awaiting" annotation, the "Retry" action, the tool labels (`agentRun.tools`,
|
|
228
|
-
keyed by raw tool name)
|
|
296
|
+
keyed by raw tool name) — resolves through the
|
|
229
297
|
`LoticsLocale` `agentRun` slice (translate once at the provider; a `vi` app gets them for free);
|
|
230
|
-
`labelForCall`
|
|
298
|
+
`labelForCall` remains a per-call-site OVERRIDE.
|
|
231
299
|
|
|
232
300
|
**Answering an awaiting call — `ApprovalPrompt`.** The `awaiting` row inside `AgentRun` is
|
|
233
301
|
READ-ONLY — it only NAMES that a call is parked on a human decision. The surface that ANSWERS it
|
|
@@ -245,7 +313,7 @@ locale slice → English.
|
|
|
245
313
|
On a canvas/composer app reach for `AgentProgress` (`@lotics/ui/agent_progress`) — `AgentRun`
|
|
246
314
|
collapsed into a floating pill (an animated `WaveAvatar` + the current step's label) that EXPANDS
|
|
247
315
|
on press to the full feed in a capped scroll panel. It takes the same `parts`/`state`/`error`, and
|
|
248
|
-
forwards `labelForCall`/`renderToolOutput`/`onRetry
|
|
316
|
+
forwards `labelForCall`/`renderToolOutput`/`onRetry` to the expanded feed, plus `label`
|
|
249
317
|
(override the collapsed text — defaults to the running step's label, else "Working…" / "Done" /
|
|
250
318
|
"Stopped"; pass it to localize — note it's one static string, so it no longer tracks the running
|
|
251
319
|
step) and `defaultExpanded`. Its pill matches `Composer`'s compact geometry, so the composer morphs
|
package/docs/catalog.md
CHANGED
|
@@ -2093,13 +2093,27 @@ component rather than showing it at zero.
|
|
|
2093
2093
|
state }`); `renderToolOutput(call)` replaces a step's default Output panel (the one render escape
|
|
2094
2094
|
hatch — input/error untouched). A per-tool failure shows amber with the reason in its expanded
|
|
2095
2095
|
Error panel; a run-level breaking `error` (outside `parts`) renders as a terminal danger row, with
|
|
2096
|
-
an optional `onRetry` Button under it.
|
|
2097
|
-
|
|
2096
|
+
an optional `onRetry` Button under it. While it runs the whole feed is ONE row and NO prose: the
|
|
2097
|
+
row names the call in flight ("Searching records") and says "Thinking…" whenever the
|
|
2098
|
+
model is writing rather than calling. The agent's between-call narration is never shown — the one
|
|
2099
|
+
message needing a response is a bulk-mutation confirmation, and the agent waits there, so the run
|
|
2100
|
+
stops and that text becomes the answer. A reply with no tool calls is all answer and still streams.
|
|
2101
|
+
At settle the work folds to "{last action}" with the answer below. Expanding is the one
|
|
2102
|
+
place live and settled differ: opened mid-run the timeline is capped and self-pinning (220px),
|
|
2103
|
+
opened settled it is uncapped. Stays fully open while a call is parked, when a SETTLED run ended on
|
|
2104
|
+
a tool call, and when the work is already one row. `collapseProcess={false}` hands the job back to
|
|
2105
|
+
the caller (a surface that already frames the run — it then owns the `FollowScroll` too);
|
|
2106
|
+
`summarizeRun(steps)` renames the row.
|
|
2107
|
+
Fixed chrome (Thinking / Input / Output /
|
|
2108
|
+
Error / awaiting / Retry) localizes via the `agentRun` locale slice. Needs `ai` as an optional type-only peer. AI
|
|
2098
2109
|
work ONLY — deterministic work (template fill, an export) shows a `loading` state on its trigger
|
|
2099
2110
|
and delivers at once; dressing it as an agent run is theater.
|
|
2100
2111
|
- **`agent_transform`** — internal (no component): the one place that folds an ai-sdk `UIMessage`'s
|
|
2101
2112
|
`parts` into the render timeline (`AgentUIPart`, `toSegments`, `lastRunningStep`), shared by
|
|
2102
|
-
`AgentRun` and `AgentProgress`.
|
|
2113
|
+
`AgentRun` and `AgentProgress`. Also `splitTimeline` → `AgentTimeline` (`{ process, result,
|
|
2114
|
+
steps }`): the run's work/answer split, cut POSITIONALLY at the last tool call because a
|
|
2115
|
+
model emits its plan, its asides and its report all as `text`. The sole file permitted to
|
|
2116
|
+
`import type` from `ai`.
|
|
2103
2117
|
- **`agent_progress`** — `AgentProgress`: `AgentRun`'s compact, floating, expandable form (same
|
|
2104
2118
|
`parts` prop) — a composer's working state.
|
|
2105
2119
|
- **`approval_prompt`** — `ApprovalPrompt`: the surface that ANSWERS an agent's approval-gated
|
package/package.json
CHANGED
package/src/agent_progress.tsx
CHANGED
|
@@ -26,8 +26,6 @@ export interface AgentProgressProps {
|
|
|
26
26
|
renderToolOutput?: AgentRunProps["renderToolOutput"];
|
|
27
27
|
/** Retry action under the expanded feed's terminal error row. */
|
|
28
28
|
onRetry?: () => void;
|
|
29
|
-
/** Localized "{n} steps" suffix, forwarded to the expanded feed. */
|
|
30
|
-
stepsLabel?: (n: number) => string;
|
|
31
29
|
defaultExpanded?: boolean;
|
|
32
30
|
}
|
|
33
31
|
|
|
@@ -40,7 +38,7 @@ export interface AgentProgressProps {
|
|
|
40
38
|
* label, so the surface stays calm until you ask to see the steps.
|
|
41
39
|
*/
|
|
42
40
|
export function AgentProgress(props: AgentProgressProps) {
|
|
43
|
-
const { parts, state = "streaming", error, label, labelForCall, renderToolOutput, onRetry,
|
|
41
|
+
const { parts, state = "streaming", error, label, labelForCall, renderToolOutput, onRetry, defaultExpanded } = props;
|
|
44
42
|
const [expanded, setExpanded] = useState(defaultExpanded ?? false);
|
|
45
43
|
|
|
46
44
|
const running = lastRunningStep(toSegments(parts));
|
|
@@ -62,7 +60,10 @@ export function AgentProgress(props: AgentProgressProps) {
|
|
|
62
60
|
{/* FollowScroll keeps the newest step in view as the run streams —
|
|
63
61
|
a plain ScrollView would let new content grow below the fold. */}
|
|
64
62
|
<FollowScroll style={{ maxHeight: 260 }} contentContainerStyle={{ padding: 16 }}>
|
|
65
|
-
|
|
63
|
+
{/* collapseProcess=false: the pill IS the fold. Letting the settled
|
|
64
|
+
run fold again inside it would put the steps two presses deep —
|
|
65
|
+
and this panel is only ever open because someone asked for them. */}
|
|
66
|
+
<AgentRun parts={parts} state={state} error={error} labelForCall={labelForCall} renderToolOutput={renderToolOutput} onRetry={onRetry} collapseProcess={false} />
|
|
66
67
|
</FollowScroll>
|
|
67
68
|
</View>
|
|
68
69
|
) : null}
|
package/src/agent_run.tsx
CHANGED
|
@@ -8,6 +8,7 @@ import { Callout, CalloutActions, CalloutText } from "./callout";
|
|
|
8
8
|
import { Markdown } from "./markdown";
|
|
9
9
|
import { JsonPanel, stringifyData } from "./json_panel";
|
|
10
10
|
import { PressableHighlight } from "./pressable_highlight";
|
|
11
|
+
import { FollowScroll } from "./follow_scroll";
|
|
11
12
|
import { Marker, type StepStatus } from "./stepper";
|
|
12
13
|
import { NODE } from "./stepper_layout";
|
|
13
14
|
import { AnimationFadeIn } from "./animation_fade_in";
|
|
@@ -17,7 +18,7 @@ import { useLoticsLocale, type LoticsLocale } from "./locale";
|
|
|
17
18
|
// The render model comes STRAIGHT from ai-sdk `UIMessage.parts` — no bespoke
|
|
18
19
|
// transcript type. `agent_transform` is the one place that folds parts into the
|
|
19
20
|
// timeline; this file is the renderer.
|
|
20
|
-
import { toSegments, anyRunning, type AgentUIPart, type AgentStep, type AgentStepStatus } from "./agent_transform";
|
|
21
|
+
import { toSegments, splitTimeline, anyRunning, lastRunningStep, type AgentUIPart, type AgentSegment, type AgentStep, type AgentStepStatus } from "./agent_transform";
|
|
21
22
|
|
|
22
23
|
/** A resolved tool call as a `labelForCall` sees it: the RAW tool name, the
|
|
23
24
|
* input it was invoked with, and its settle state. Richer than a name-only map,
|
|
@@ -64,9 +65,21 @@ export interface AgentRunProps {
|
|
|
64
65
|
/** When the run ended on a terminal `error`, render a retry action under that
|
|
65
66
|
* danger row. Omit it and the error row renders exactly as before. */
|
|
66
67
|
onRetry?: () => void;
|
|
67
|
-
/**
|
|
68
|
-
*
|
|
69
|
-
|
|
68
|
+
/** Fold a SETTLED run's work into ONE summary row, leaving the answer as the
|
|
69
|
+
* only thing at rest (`splitTimeline`). Default `true`. Set `false` where the
|
|
70
|
+
* run is ALREADY inside a collapsed shell — `AgentProgress`'s pill — since a
|
|
71
|
+
* fold behind a fold costs two presses to read one run. */
|
|
72
|
+
collapseProcess?: boolean;
|
|
73
|
+
/** Name the whole run on that summary row. Gets every tool step in order;
|
|
74
|
+
* return `undefined` to fall back to the built-in label (the LAST step's).
|
|
75
|
+
*
|
|
76
|
+
* The default names the last action and nothing more, deliberately: only the
|
|
77
|
+
* host knows which of its tools WROTE, and a kit-side guess would be a list
|
|
78
|
+
* of tool names that is silently incomplete the moment a write tool is added
|
|
79
|
+
* — reporting a run that changed data as if it had only read. A host that
|
|
80
|
+
* does know should say so here ("Updated 3 records"), since that is the one
|
|
81
|
+
* fact worth reading without expanding. */
|
|
82
|
+
summarizeRun?: (steps: readonly AgentStep[]) => string | undefined;
|
|
70
83
|
accessibilityLabel?: string;
|
|
71
84
|
}
|
|
72
85
|
|
|
@@ -141,12 +154,18 @@ const INK = colors.zinc[700];
|
|
|
141
154
|
* tools it calls, in the order they happened. While the agent is mid-tools the
|
|
142
155
|
* tail group is a SINGLE pulsing row whose label swaps in place as each call
|
|
143
156
|
* fires (not a growing stack of dots); once prose resumes the group settles into
|
|
144
|
-
* one row — "{final action}
|
|
145
|
-
*
|
|
146
|
-
*
|
|
157
|
+
* one row — "{final action}" — that EXPANDS on press to the steps in between.
|
|
158
|
+
* Text segments render as prose.
|
|
159
|
+
*
|
|
160
|
+
* That no-stacking law holds for the WHOLE run, not just one burst: the work
|
|
161
|
+
* stays at ONE row from the first call to the last, its label swapping in place
|
|
162
|
+
* to whatever is happening now, and the answer is the only thing that renders
|
|
163
|
+
* below it (`collapseProcess`, on by default). Press the row at any time —
|
|
164
|
+
* mid-run included — to roll the timeline out under it. Pair with `Composer` +
|
|
165
|
+
* a review surface (`DiffValue` / `useChangeSet`).
|
|
147
166
|
*/
|
|
148
167
|
export function AgentRun(props: AgentRunProps) {
|
|
149
|
-
const { parts, labelForCall, renderToolOutput, onRetry,
|
|
168
|
+
const { parts, labelForCall, renderToolOutput, onRetry, summarizeRun, accessibilityLabel } = props;
|
|
150
169
|
const locale = useLoticsLocale();
|
|
151
170
|
const segments = toSegments(parts);
|
|
152
171
|
const state = props.state ?? (anyRunning(segments) ? "streaming" : "done");
|
|
@@ -155,6 +174,70 @@ export function AgentRun(props: AgentRunProps) {
|
|
|
155
174
|
|
|
156
175
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
|
157
176
|
const toggle = (id: string) => setExpanded((e) => ({ ...e, [id]: !e[id] }));
|
|
177
|
+
const [workOpen, setWorkOpen] = useState(false);
|
|
178
|
+
|
|
179
|
+
const { process, result, steps } = splitTimeline(segments);
|
|
180
|
+
const awaiting = steps.some((s) => s.status === "awaiting");
|
|
181
|
+
// Does this run manage its own process zone? A surface that ALREADY frames the
|
|
182
|
+
// run — `AgentProgress`'s pill, a demo about one step's output — opts out and
|
|
183
|
+
// gets the raw timeline.
|
|
184
|
+
const managed = (props.collapseProcess ?? true) && !awaiting;
|
|
185
|
+
|
|
186
|
+
// The work is COLLAPSED for the whole run, not just at the end — one row whose
|
|
187
|
+
// label follows what is happening now. Nothing rolls out unless the reader asks.
|
|
188
|
+
//
|
|
189
|
+
// WHILE STREAMING that covers EVERY segment, prose included. The agent's
|
|
190
|
+
// between-call text is narration by instruction — a plan it was told to state
|
|
191
|
+
// up front, asides capped at two sentences — and none of it is load-bearing in
|
|
192
|
+
// flight: the one message that needs an answer is a bulk-mutation confirmation,
|
|
193
|
+
// and the agent WAITS there, so the run stops and that text becomes the
|
|
194
|
+
// trailing text this renders below. Showing the rest bought a sentence that
|
|
195
|
+
// appeared, was read halfway, and vanished when the next call superseded it.
|
|
196
|
+
// The threshold is `process.length > 0` — a reply with no work at all is all
|
|
197
|
+
// answer, and hiding THAT behind a row until settle would be the real loss.
|
|
198
|
+
//
|
|
199
|
+
// SETTLED, the collapse covers the work and the answer stands below it.
|
|
200
|
+
// Expanding differs between the two, and only there: LIVE the timeline is
|
|
201
|
+
// capped and self-pinning (opening a run mid-flight must not hand the page a
|
|
202
|
+
// feed that grows for another minute), SETTLED it opens in full.
|
|
203
|
+
//
|
|
204
|
+
// The settled clauses are cases where collapsing would hide something needed:
|
|
205
|
+
// · no answer — the run stopped ON a tool call, so the fold would leave an
|
|
206
|
+
// empty message. An unfinished run should look unfinished.
|
|
207
|
+
// · one process row — already one row. Folding buys no rows and costs a
|
|
208
|
+
// second press to reach a step's I/O.
|
|
209
|
+
// · no tool steps — thinking plus a stray sentence, and `Thinking` is already
|
|
210
|
+
// a collapsed row. A summary row here could only name a step that never ran.
|
|
211
|
+
const folded = managed && (streaming ? process.length > 0 : process.length > 1 && steps.length > 0 && result.length > 0);
|
|
212
|
+
|
|
213
|
+
// What the row SAYS right now: the tool in flight, or — when the model is
|
|
214
|
+
// writing rather than calling — "Thinking…". Naming the last finished action
|
|
215
|
+
// while the agent composes prose reports a moment that has passed.
|
|
216
|
+
const thinking = streaming && !lastRunningStep(segments);
|
|
217
|
+
|
|
218
|
+
const renderSegment = (seg: AgentSegment, tail: boolean) => {
|
|
219
|
+
if (seg.kind === "text") {
|
|
220
|
+
return (
|
|
221
|
+
<View key={seg.id} style={styles.narration}>
|
|
222
|
+
<Markdown>{seg.text}</Markdown>
|
|
223
|
+
</View>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (seg.kind === "reasoning") {
|
|
227
|
+
return <ReasoningDisclosure key={seg.id} text={seg.text} streaming={streaming && tail} expanded={!!expanded[seg.id]} onToggle={() => toggle(seg.id)} />;
|
|
228
|
+
}
|
|
229
|
+
return (
|
|
230
|
+
<ToolGroup
|
|
231
|
+
key={seg.id}
|
|
232
|
+
steps={seg.steps}
|
|
233
|
+
active={streaming && tail}
|
|
234
|
+
expanded={!!expanded[seg.id]}
|
|
235
|
+
onToggle={() => toggle(seg.id)}
|
|
236
|
+
labelForCall={labelForCall}
|
|
237
|
+
renderToolOutput={renderToolOutput}
|
|
238
|
+
/>
|
|
239
|
+
);
|
|
240
|
+
};
|
|
158
241
|
|
|
159
242
|
return (
|
|
160
243
|
<View accessibilityLabel={accessibilityLabel} style={{ gap: 10 }}>
|
|
@@ -165,30 +248,32 @@ export function AgentRun(props: AgentRunProps) {
|
|
|
165
248
|
CTA press (uploads included) and never hand-roll a text/skeleton
|
|
166
249
|
placeholder in front of the feed. */}
|
|
167
250
|
{streaming && segments.length === 0 ? <StartingRow label={locale.agentRun.starting} /> : null}
|
|
168
|
-
{
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
if (seg.kind === "reasoning") {
|
|
177
|
-
return <ReasoningDisclosure key={seg.id} text={seg.text} streaming={streaming && i === lastIndex} expanded={!!expanded[seg.id]} onToggle={() => toggle(seg.id)} />;
|
|
178
|
-
}
|
|
179
|
-
return (
|
|
180
|
-
<ToolGroup
|
|
181
|
-
key={seg.id}
|
|
182
|
-
steps={seg.steps}
|
|
183
|
-
active={streaming && i === lastIndex}
|
|
184
|
-
expanded={!!expanded[seg.id]}
|
|
185
|
-
onToggle={() => toggle(seg.id)}
|
|
251
|
+
{folded ? (
|
|
252
|
+
<>
|
|
253
|
+
<WorkSummary
|
|
254
|
+
steps={steps}
|
|
255
|
+
live={streaming}
|
|
256
|
+
thinking={thinking}
|
|
257
|
+
expanded={workOpen}
|
|
258
|
+
onToggle={() => setWorkOpen((o) => !o)}
|
|
186
259
|
labelForCall={labelForCall}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
260
|
+
summarizeRun={summarizeRun}
|
|
261
|
+
>
|
|
262
|
+
{/* Streaming, the collapse holds EVERY segment — the trailing prose
|
|
263
|
+
has nowhere else to be, and that is the point. Settled, it holds
|
|
264
|
+
the work and the answer stands below. Opened mid-run the tail
|
|
265
|
+
group still pulses in there. */}
|
|
266
|
+
{(streaming ? segments : process).map((seg, i, all) => renderSegment(seg, streaming && i === all.length - 1))}
|
|
267
|
+
</WorkSummary>
|
|
268
|
+
{/* The answer, once there IS one. It appears at settle rather than
|
|
269
|
+
streaming, and that is the trade: our final responses are ≤3
|
|
270
|
+
sentences by instruction, so what is given up is a second of typing
|
|
271
|
+
— against never yanking a half-read sentence out from under anyone. */}
|
|
272
|
+
{streaming ? null : result.map((seg) => renderSegment(seg, false))}
|
|
273
|
+
</>
|
|
274
|
+
) : (
|
|
275
|
+
segments.map((seg, i) => renderSegment(seg, i === lastIndex))
|
|
276
|
+
)}
|
|
192
277
|
{/* A run-level BREAKING error terminates the feed. It lives outside `parts`
|
|
193
278
|
(the stream failed), so the caller passes it explicitly.
|
|
194
279
|
A CONTAINED surface, not another step row: the run didn't advance, it
|
|
@@ -382,17 +467,14 @@ function ReasoningDisclosure(props: { text: string; streaming?: boolean; expande
|
|
|
382
467
|
);
|
|
383
468
|
}
|
|
384
469
|
|
|
385
|
-
|
|
470
|
+
// The row says WHAT is happening and nothing else. A step count was a number the
|
|
471
|
+
// reader could do nothing with: on a live row the label already changes on every
|
|
472
|
+
// call, so the tally added no motion it did not have; on a settled one it sized
|
|
473
|
+
// work nobody had asked to size. The chevron carries "there is more inside".
|
|
474
|
+
function SummaryLabel({ label }: { label: string }) {
|
|
386
475
|
return (
|
|
387
|
-
// PARENTHESES, not a middot. The count is supplementary to the action, and
|
|
388
|
-
// parentheses say so in a mark that survives text extraction and screen
|
|
389
|
-
// readers; a middot asserts a relationship without naming it, and the ink
|
|
390
|
-
// contrast here was already doing the separating anyway.
|
|
391
476
|
<Text size="sm" weight="medium" numberOfLines={1}>
|
|
392
477
|
{label}
|
|
393
|
-
<Text size="sm" color="muted">
|
|
394
|
-
{" (" + stepsLabel(count) + ")"}
|
|
395
|
-
</Text>
|
|
396
478
|
</Text>
|
|
397
479
|
);
|
|
398
480
|
}
|
|
@@ -401,6 +483,90 @@ function chevron(dir: "down" | "up") {
|
|
|
401
483
|
return <Icon name={dir === "down" ? "chevron-down" : "chevron-up"} size={16} color={colors.zinc[400]} />;
|
|
402
484
|
}
|
|
403
485
|
|
|
486
|
+
/**
|
|
487
|
+
* The run's WORK, held at ONE row — the whole timeline up to the answer, behind
|
|
488
|
+
* a single "{current or last action}" header that expands the work out BELOW it,
|
|
489
|
+
* in order, exactly as it renders unfolded.
|
|
490
|
+
*
|
|
491
|
+
* ONE row for the whole lifecycle, not just at the end. Live, the label swaps in
|
|
492
|
+
* place under a pulsing dot; settled, it becomes the done summary. That is the
|
|
493
|
+
* point: a feed that grows a finished row per call makes the reader watch the
|
|
494
|
+
* work scroll past, and then leaves the wreckage sitting above the answer forever.
|
|
495
|
+
*
|
|
496
|
+
* It is the run-level analogue of `ToolGroup`'s header, and it exists because
|
|
497
|
+
* that grouping alone was never enough — a group breaks on every prose part AND
|
|
498
|
+
* every reasoning part, so an interleaved-thinking run rebuilds the stack one
|
|
499
|
+
* group at a time and settles as a pile of collapsed rows plus whatever plan it
|
|
500
|
+
* narrated on the way, all at the answer's weight and all stale. At rest a
|
|
501
|
+
* reader wants the answer; the work is what they open when they doubt it.
|
|
502
|
+
*/
|
|
503
|
+
function WorkSummary(props: {
|
|
504
|
+
steps: AgentStep[];
|
|
505
|
+
live: boolean;
|
|
506
|
+
/** The model is WRITING rather than calling — no tool is in flight. */
|
|
507
|
+
thinking: boolean;
|
|
508
|
+
expanded: boolean;
|
|
509
|
+
onToggle: () => void;
|
|
510
|
+
labelForCall?: (call: AgentToolCall) => string | undefined;
|
|
511
|
+
summarizeRun?: AgentRunProps["summarizeRun"];
|
|
512
|
+
children: ReactNode;
|
|
513
|
+
}) {
|
|
514
|
+
const { steps, live, thinking, expanded, onToggle, labelForCall, summarizeRun, children } = props;
|
|
515
|
+
const locale = useLoticsLocale();
|
|
516
|
+
const final = steps[steps.length - 1];
|
|
517
|
+
// "Thinking…" WINS over a host summary while the model writes: `summarizeRun`
|
|
518
|
+
// names what the run DID, and mid-compose that is a moment already past.
|
|
519
|
+
const label = thinking
|
|
520
|
+
? locale.agentRun.thinkingStreaming
|
|
521
|
+
: (summarizeRun?.(steps) ?? stepLabel(final, labelForCall, locale.agentRun.tools));
|
|
522
|
+
// A failure ANYWHERE in the work marks the closed row, so a run that limped to
|
|
523
|
+
// its answer never reads as clean work — the amber is the reason to open it.
|
|
524
|
+
// While the run is still STREAMING the marker is the pulsing `current` dot —
|
|
525
|
+
// keyed off the run, not off whether a call happens to be in flight this
|
|
526
|
+
// instant, or the dot would settle and re-pulse every time the agent stopped to
|
|
527
|
+
// write a sentence. An error that already happened waits its turn too: a run in
|
|
528
|
+
// flight is not a run that failed, and amber mid-flight would say it stopped.
|
|
529
|
+
const errored = steps.some((s) => s.status === "error");
|
|
530
|
+
return (
|
|
531
|
+
<View style={styles.group}>
|
|
532
|
+
<ActivityRow
|
|
533
|
+
markerStatus={live ? "current" : errored ? "warning" : "complete"}
|
|
534
|
+
live={live}
|
|
535
|
+
onPress={onToggle}
|
|
536
|
+
accessibilityLabel={label}
|
|
537
|
+
trailing={chevron(expanded ? "up" : "down")}
|
|
538
|
+
>
|
|
539
|
+
{/* Keyed by what the row is SAYING, so a new call (or the switch to
|
|
540
|
+
"Thinking…") rises + fades into the SAME row — the label swaps, the
|
|
541
|
+
row does not move. Exactly what a settled `ToolGroup` does mid-burst,
|
|
542
|
+
held for the whole run. */}
|
|
543
|
+
<AnimationFadeIn key={live ? label : "settled"} translateY={4}>
|
|
544
|
+
<SummaryLabel label={label} />
|
|
545
|
+
</AnimationFadeIn>
|
|
546
|
+
</ActivityRow>
|
|
547
|
+
{/* The run's own 10px rhythm, not the group's 2px: what rolls out here is
|
|
548
|
+
whole segments — prose, thinking, tool groups — not sibling step rows.
|
|
549
|
+
Opened while the run is still LIVE it is also CAPPED and self-pinning:
|
|
550
|
+
a reader who opens a run mid-flight asked to see the work, not to hand
|
|
551
|
+
the page a feed that grows for another minute. `FollowScroll` follows at
|
|
552
|
+
LAYOUT level (an inverted single-cell list), so each new call paints
|
|
553
|
+
already pinned — no scroll-after-paint flash. Settled, it opens in full:
|
|
554
|
+
nothing is arriving, so there is nothing to cap. */}
|
|
555
|
+
{expanded ? (
|
|
556
|
+
live ? (
|
|
557
|
+
<View testID="agent-run-work-capped" style={styles.work}>
|
|
558
|
+
<FollowScroll style={styles.liveFrame}>
|
|
559
|
+
<View style={{ gap: 10 }}>{children}</View>
|
|
560
|
+
</FollowScroll>
|
|
561
|
+
</View>
|
|
562
|
+
) : (
|
|
563
|
+
<View testID="agent-run-work" style={styles.work}>{children}</View>
|
|
564
|
+
)
|
|
565
|
+
) : null}
|
|
566
|
+
</View>
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
404
570
|
function ToolGroup(props: {
|
|
405
571
|
steps: AgentStep[];
|
|
406
572
|
active: boolean;
|
|
@@ -408,9 +574,8 @@ function ToolGroup(props: {
|
|
|
408
574
|
onToggle: () => void;
|
|
409
575
|
labelForCall?: (call: AgentToolCall) => string | undefined;
|
|
410
576
|
renderToolOutput?: AgentRunProps["renderToolOutput"];
|
|
411
|
-
stepsLabel: (n: number) => string;
|
|
412
577
|
}) {
|
|
413
|
-
const { steps, active, expanded, onToggle, labelForCall, renderToolOutput
|
|
578
|
+
const { steps, active, expanded, onToggle, labelForCall, renderToolOutput } = props;
|
|
414
579
|
const locale = useLoticsLocale();
|
|
415
580
|
const resolve = (s: AgentStep) => stepLabel(s, labelForCall, locale.agentRun.tools);
|
|
416
581
|
|
|
@@ -454,7 +619,7 @@ function ToolGroup(props: {
|
|
|
454
619
|
);
|
|
455
620
|
}
|
|
456
621
|
|
|
457
|
-
// SETTLED, expandable — a persistent "{final action}
|
|
622
|
+
// SETTLED, expandable — a persistent "{final action}" HEADER that
|
|
458
623
|
// STAYS PUT and rolls the steps out BELOW it (in order) on press, so the row you
|
|
459
624
|
// pressed never moves. The header wears the `complete` terminal dot — an outlined
|
|
460
625
|
// ring + check, a differentiator from the filled `done` step dots; every row
|
|
@@ -464,10 +629,10 @@ function ToolGroup(props: {
|
|
|
464
629
|
<ActivityRow
|
|
465
630
|
markerStatus={errored || awaiting ? "warning" : "complete"}
|
|
466
631
|
onPress={onToggle}
|
|
467
|
-
accessibilityLabel={
|
|
632
|
+
accessibilityLabel={resolve(final)}
|
|
468
633
|
trailing={chevron(expanded ? "up" : "down")}
|
|
469
634
|
>
|
|
470
|
-
<SummaryLabel label={resolve(final)}
|
|
635
|
+
<SummaryLabel label={resolve(final)} />
|
|
471
636
|
</ActivityRow>
|
|
472
637
|
{expanded
|
|
473
638
|
? steps.map((s) => (
|
|
@@ -486,6 +651,15 @@ const styles = StyleSheet.create({
|
|
|
486
651
|
// lines in every padded container).
|
|
487
652
|
narration: {},
|
|
488
653
|
group: { gap: 2 },
|
|
654
|
+
// The folded work, opened: segment spacing (the run's own gap), and a small
|
|
655
|
+
// lead-in under the header so the rolled-out work reads as its content.
|
|
656
|
+
work: { gap: 10, paddingTop: 6 },
|
|
657
|
+
// The cap on work OPENED mid-run. ~5 rows: enough to read what is happening,
|
|
658
|
+
// short enough that opening a p90 94s run does not push the composer off the
|
|
659
|
+
// screen for the rest of it. No border and no background — the run sits flush
|
|
660
|
+
// in the consumer's gutter by law, and a frame that appeared only while
|
|
661
|
+
// streaming would be the loudest thing on the surface.
|
|
662
|
+
liveFrame: { maxHeight: 220 },
|
|
489
663
|
row: {
|
|
490
664
|
flexDirection: "row",
|
|
491
665
|
alignItems: "center",
|
package/src/agent_run_pane.tsx
CHANGED
|
@@ -178,12 +178,17 @@ export function AgentRunPane<TLanding>(props: AgentRunPaneProps<TLanding>) {
|
|
|
178
178
|
`agentRun` locale slice — and ai_patterns states the law outright:
|
|
179
179
|
never hand-roll a placeholder in front of the feed. A slot here would
|
|
180
180
|
invite exactly that, and every app would localize it again. */}
|
|
181
|
+
{/* collapseProcess=false: this pane took the framing job — the FollowScroll
|
|
182
|
+
above IS the bound, sized by the dialog. Leaving the default on would
|
|
183
|
+
nest the run's own 220px window inside it, and the dialog's scroller
|
|
184
|
+
would then never overflow while the inner one held every step. */}
|
|
181
185
|
<AgentRun
|
|
182
186
|
parts={run.parts}
|
|
183
187
|
state={run.status === "error" ? "error" : run.status === "streaming" ? "streaming" : "done"}
|
|
184
188
|
error={run.error ?? undefined}
|
|
185
189
|
labelForCall={labelForCall}
|
|
186
190
|
renderToolOutput={renderToolOutput}
|
|
191
|
+
collapseProcess={false}
|
|
187
192
|
/>
|
|
188
193
|
</FollowScroll>
|
|
189
194
|
);
|
package/src/agent_transform.ts
CHANGED
|
@@ -112,3 +112,37 @@ export function lastRunningStep(segments: readonly AgentSegment[]): AgentStep |
|
|
|
112
112
|
}
|
|
113
113
|
return undefined;
|
|
114
114
|
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A settled run reads as two zones: the WORK it did, and the ANSWER it ended on.
|
|
118
|
+
*
|
|
119
|
+
* The split is POSITIONAL. Nothing inside a part says whether its prose is a
|
|
120
|
+
* plan, a between-call aside, or the report — a model emits all three as `text`,
|
|
121
|
+
* and deciding between them would be guessing at language. Position is the one
|
|
122
|
+
* thing that IS knowable: what comes after the last tool call is what the agent
|
|
123
|
+
* said once the work was over; everything before it is the work.
|
|
124
|
+
*/
|
|
125
|
+
export interface AgentTimeline {
|
|
126
|
+
/** The work, in order — prose narrated on the way, thinking, tool groups. */
|
|
127
|
+
process: AgentSegment[];
|
|
128
|
+
/** The trailing text segments: what the run ended on. EMPTY when the run
|
|
129
|
+
* stopped on a tool call (aborted, out of steps, parked) — which is exactly
|
|
130
|
+
* when the work must NOT be folded away, because nothing would be left. */
|
|
131
|
+
result: AgentSegment[];
|
|
132
|
+
/** Every tool step in `process`, flattened in order — what a run-level
|
|
133
|
+
* summary counts and names. */
|
|
134
|
+
steps: AgentStep[];
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Split a folded timeline into its work and its answer. Pure: the returned
|
|
138
|
+
* arrays are fresh, and nothing in `segments` is mutated. */
|
|
139
|
+
export function splitTimeline(segments: readonly AgentSegment[]): AgentTimeline {
|
|
140
|
+
let cut = segments.length;
|
|
141
|
+
while (cut > 0 && segments[cut - 1].kind === "text") cut -= 1;
|
|
142
|
+
const process = segments.slice(0, cut);
|
|
143
|
+
const steps: AgentStep[] = [];
|
|
144
|
+
for (const seg of process) {
|
|
145
|
+
if (seg.kind === "group") steps.push(...seg.steps);
|
|
146
|
+
}
|
|
147
|
+
return { process, result: segments.slice(cut), steps };
|
|
148
|
+
}
|
package/src/locale.tsx
CHANGED
|
@@ -250,10 +250,9 @@ export interface LoticsLocale {
|
|
|
250
250
|
textInputField: { clear: string };
|
|
251
251
|
/** `AgentRun`: the reasoning disclosure's label (settled / streaming), the
|
|
252
252
|
* auto-built tool peek's Input / Error / Output panel titles, the `awaiting`
|
|
253
|
-
* annotation on a call parked on a human decision,
|
|
254
|
-
* `retry` action, the per-tool labels (`tools`)
|
|
255
|
-
*
|
|
256
|
-
* OVERRIDES, not as the only way to get a translation. */
|
|
253
|
+
* annotation on a call parked on a human decision, the terminal error's
|
|
254
|
+
* `retry` action, and the per-tool labels (`tools`). `labelForCall` remains a
|
|
255
|
+
* per-call-site OVERRIDE, not the only way to get a translation. */
|
|
257
256
|
/** `AgentRun` / `AgentProgress` chrome, plus `tools` — the display label per
|
|
258
257
|
* PLATFORM tool name. The tool set is bounded and kit-known, so localizing it
|
|
259
258
|
* here means every app inherits it; leaving it to each call site's
|
|
@@ -271,7 +270,6 @@ export interface LoticsLocale {
|
|
|
271
270
|
retry: string;
|
|
272
271
|
stop: string;
|
|
273
272
|
tools: Record<string, string>;
|
|
274
|
-
steps: (n: number) => string;
|
|
275
273
|
};
|
|
276
274
|
/** `ApprovalPrompt`: the default prompt line (overridable per instance) and
|
|
277
275
|
* the Approve / Deny button labels — the surface that ANSWERS `AgentRun`'s
|
|
@@ -489,7 +487,6 @@ export const en: LoticsLocale = {
|
|
|
489
487
|
generate_excel_from_template: "Generating spreadsheet",
|
|
490
488
|
generate_docx_from_template: "Generating document",
|
|
491
489
|
},
|
|
492
|
-
steps: (n) => `${n} steps`,
|
|
493
490
|
},
|
|
494
491
|
approvalPrompt: { message: "The assistant wants to perform an action that needs your approval.", approve: "Approve", deny: "Deny" },
|
|
495
492
|
messageActions: { copy: "Copy", copied: "Copied", regenerate: "Regenerate", edit: "Edit", previousVersion: "Previous version", nextVersion: "Next version" },
|
|
@@ -697,7 +694,6 @@ export const vi: LoticsLocale = {
|
|
|
697
694
|
generate_excel_from_template: "Đang tạo bảng tính",
|
|
698
695
|
generate_docx_from_template: "Đang tạo văn bản",
|
|
699
696
|
},
|
|
700
|
-
steps: (n) => `${n} bước`,
|
|
701
697
|
},
|
|
702
698
|
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" },
|
|
703
699
|
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" },
|