@lotics/ui 45.10.0 → 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/docs/testing.md CHANGED
@@ -3,8 +3,7 @@
3
3
  Three kit anatomies where the accessibility tree says one thing and an automated
4
4
  driver has to do another. Each is **by design** — the shape that makes the
5
5
  component correct for a keyboard and a screen reader is the shape that defeats a
6
- naive `click()` — so each will read as a bug the first time, and the wrong
7
- reaction (`force: true`, or "the component is broken") costs a debugging session.
6
+ naive `click()` — so each will read as a bug the first time.
8
7
 
9
8
  Everything here is true wherever the kit renders. The iframe that a Lotics app
10
9
  runs inside adds one more rule on top — `lotics docs building_an_app` § 8.
@@ -25,9 +24,7 @@ So the a11y tree shows `button "Open ACME-1042"`, and clicking it fails:
25
24
  subtree intercepts pointer events
26
25
  ```
27
26
 
28
- That is the design working, not a defect the visible row is above the door
29
- precisely so a pointer lands on the row rather than the door. Drive it the way a
30
- mouse user does:
27
+ That is the design working, not a defect. Drive it the way a mouse user does:
31
28
 
32
29
  - **click the row container** — the `generic [cursor=pointer]` wrapping the door;
33
30
  the click bubbles to `PressableRow`
@@ -66,5 +63,4 @@ and the window are the right ones. From the dragged element,
66
63
 
67
64
  Then check **both** halves: re-snapshot for the optimistic move, and re-read the
68
65
  record to confirm the mutation actually persisted. An optimistic move that never
69
- reached the server looks identical on screen, which is the whole reason to check
70
- the second one.
66
+ reached the server looks identical on screen.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "45.10.0",
3
+ "version": "46.0.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -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, stepsLabel, defaultExpanded } = props;
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
- <AgentRun parts={parts} state={state} error={error} labelForCall={labelForCall} renderToolOutput={renderToolOutput} onRetry={onRetry} stepsLabel={stepsLabel} />
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
- /** Localized "{n} steps" suffix on a collapsed activity group (the kit is
68
- * locale-neutral). Default `"{n} steps"`. */
69
- stepsLabel?: (n: number) => string;
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} ({n} steps)" — that EXPANDS on press to the steps in
145
- * between. Text segments render as prose. The run always ends on the agent's text.
146
- * Pair with `Composer` + a review surface (`DiffValue` / `useChangeSet`).
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, stepsLabel, accessibilityLabel } = props;
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
- {segments.map((seg, i) => {
169
- if (seg.kind === "text") {
170
- return (
171
- <View key={seg.id} style={styles.narration}>
172
- <Markdown>{seg.text}</Markdown>
173
- </View>
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
- renderToolOutput={renderToolOutput}
188
- stepsLabel={stepsLabel ?? locale.agentRun.steps}
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
- function SummaryLabel({ label, count, stepsLabel }: { label: string; count: number; stepsLabel: (n: number) => string }) {
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, stepsLabel } = props;
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} ({n} steps)" HEADER that
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={`${resolve(final)} — ${stepsLabel(steps.length)}`}
632
+ accessibilityLabel={resolve(final)}
468
633
  trailing={chevron(expanded ? "up" : "down")}
469
634
  >
470
- <SummaryLabel label={resolve(final)} count={steps.length} stepsLabel={stepsLabel} />
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",
@@ -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
  );
@@ -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, and the terminal error's
254
- * `retry` action, the per-tool labels (`tools`) and the "{n} steps" suffix
255
- * (`steps`). `labelForCall` / `stepsLabel` remain as per-call-site
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" },
@@ -14,10 +14,11 @@ export interface PressableRowProps {
14
14
  marked?: boolean;
15
15
  /**
16
16
  * - "register" (THE record-list default): a rounded row whose hover/open/`marked`
17
- * wash spans the FULL row width (incl. nested controls); content stays on the
18
- * 20px column gutter so a `Table` header and its cells still align. The `Table`
19
- * separates rows with a `Divider` (the wash is the hover/selection state, the
20
- * hairline the resting separation) — every app inherits this register look.
17
+ * wash spans the FULL row width (incl. nested controls); the wash BLEEDS outward
18
+ * and the content stays on the container's own edge, so a `Table` header and its
19
+ * cells still align. The `Table` draws NOTHING between rows it spaces them by
20
+ * 4px and the wash is the only mark a row makes — every app inherits this
21
+ * register look.
21
22
  * - "bleed" (legacy): px-20, square wash to the edges, `Divider`-separated.
22
23
  * The pre-rounded register row — kept for an edge-to-edge data grid that
23
24
  * genuinely wants hard rules, not the floating default.
@@ -143,7 +144,8 @@ const styles = StyleSheet.create({
143
144
  paddingHorizontal: ROW_WASH_BLEED,
144
145
  marginHorizontal: -ROW_WASH_BLEED,
145
146
  },
146
- // Square, full-bleed — lines up with the Dividers between register rows. Legacy.
147
+ // Square, full-bleed — the pre-rounded register row, for a grid that draws its own
148
+ // rules between rows. The register variant draws none. Legacy.
147
149
  bleed: {
148
150
  paddingHorizontal: ROW_WASH_BLEED,
149
151
  marginHorizontal: -ROW_WASH_BLEED,
package/src/table.tsx CHANGED
@@ -181,9 +181,9 @@ export interface TableProps {
181
181
  * THE columnar register — define `columns` once and the header band + every row's
182
182
  * cell widths come from it, so they can't drift (no hand-rolled `W` map, no
183
183
  * `<View style={{width}}>` per cell). Renders a full-bleed eyebrow header (a
184
- * sortable column becomes a `SortHeader`) and its `TableRow` children,
185
- * `Divider`-separated. Compose `TableRow` / `TableCell` for the body. For a
186
- * non-columnar list (entity piles, card stacks) use `PressableRow` directly.
184
+ * sortable column becomes a `SortHeader`) and its `TableRow` children, spaced by
185
+ * 4px with no rule between them. Compose `TableRow` / `TableCell` for the body.
186
+ * For a non-columnar list (entity piles, card stacks) use `PressableRow` directly.
187
187
  *
188
188
  * The register is container-responsive with no prop: when the measured width
189
189
  * can't fit every column it drops droppable columns by `priority`, and below
@@ -312,10 +312,10 @@ export interface TableGroupProps {
312
312
  * column: that is a sort, and it costs a band of chrome per value while telling
313
313
  * them what the cell beside it says.
314
314
  *
315
- * The band separates itself with AIR rather than a rule, because it is the
316
- * biggest boundary inside the register and the row hairlines are the smallest
317
- * drawing both as lines makes them compete. The heading also takes no rule
318
- * beneath it, which would orphan the title from the rows it opens.
315
+ * The band separates itself with AIR rather than a rule, because the register
316
+ * already separates its own rows with air and keeps ONE line the band capping
317
+ * the columns so a second would compete with it. The heading takes no rule
318
+ * beneath it either, which would orphan the title from the rows it opens.
319
319
  *
320
320
  * A group's rows keep their own `ordinal` numbering if they carry one: restart
321
321
  * inside each band, where the reader's question is "which of these" rather than
@@ -609,8 +609,8 @@ const styles = StyleSheet.create({
609
609
  paddingTop: 16,
610
610
  paddingBottom: 16,
611
611
  },
612
- // A hairline under the column header anchors the columns; the rows below it are
613
- // Divider-separated.
612
+ // A hairline under the column header anchors the columns; the rows below it carry
613
+ // none — see the file header for why that boundary is the register's only rule.
614
614
  headerBandFilled: {
615
615
  // The literal neutral, not `accent_wash`: this band is a lid on the columns,
616
616
  // never a "you are here", and reading it through the brand token made a
@@ -644,11 +644,12 @@ const styles = StyleSheet.create({
644
644
  body: {
645
645
  gap: 4,
646
646
  },
647
- // Air, not a rule. A band boundary is the LARGEST break inside a register and
648
- // the row hairlines are the smallest; drawing both as lines makes the two
649
- // compete and the grouping stops reading. The first band's heading sits under
650
- // the column band's own hairline, so it takes less top space than the ones
651
- // that follow handled by the heading's own padding rather than by the caller.
647
+ // Air, not a rule. A band boundary is the LARGEST break inside a register, and the
648
+ // register does not rule the smallest one either rows are spaced, not ruled — so
649
+ // a line here would be a second rule competing with the column band's, and the
650
+ // grouping stops reading. The first band's heading sits under the column band's own
651
+ // hairline, so it takes less top space than the ones that follow handled by the
652
+ // heading's own padding rather than by the caller.
652
653
  groupHeading: {
653
654
  flexDirection: "row",
654
655
  alignItems: "center",