@lotics/ui 14.0.0 → 14.1.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.
@@ -136,6 +136,13 @@ move on, so the feed doesn't dramatize it. A BREAKING error that terminates the
136
136
  OUTSIDE `parts` (chat persists it in the message's `errors`; `useAgentRun().error` carries it) —
137
137
  pass it as the `error` prop and it renders as a terminal danger row under the transcript.
138
138
 
139
+ **In a bounded container, wrap it in `FollowScroll`.** An `AgentRun` streaming inside a dialog,
140
+ drawer, or fixed-height panel grows BELOW the fold — a plain scroll container doesn't follow. Wrap
141
+ the feed: `<FollowScroll style={{ maxHeight: … }}><AgentRun … /></FollowScroll>` — pinned to the
142
+ newest content while streaming, unpinned when the human scrolls up to read (`AgentProgress`'s
143
+ expanded panel already does this). A full-page inverted list (chat) follows by construction and
144
+ doesn't need it.
145
+
139
146
  Fed natively by `@lotics/app-sdk`'s `useAgentRun().parts` (reasoning + per-tool I/O + state come
140
147
  for free — no hand-assembly): `<AgentRun parts={run.parts} state={…} error={run.error} />` — see
141
148
  [the SDK doc](../../app-sdk/docs/ai.md) for the hook. Requires `ai` as an optional (type-only) peer.
@@ -268,7 +275,7 @@ reads as the custom answer).
268
275
 
269
276
  `ClarifyWizard` (`@lotics/ui/clarify_wizard`): a SEQUENCE of clarify questions worked one at a time —
270
277
  Back / Next / Cancel / Submit (Next/Submit primary), the step position an eyebrow ("1 / 3") above the
271
- question — no progress bar. Each step is a `Clarify`; advance only once the
278
+ question — no progress bar, and no eyebrow at all for a single question. Each step is a `Clarify`; advance only once the
272
279
  current question is answered; `onSubmit` gives one `{ value, custom }` per question (aligned by index,
273
280
  `custom` true when the answer came from "Other…"), `onCancel` dismisses. All navigation chrome is
274
281
  locale-wired (the `clarify` slice). The multi-question form of the ask-back.
package/docs/catalog.md CHANGED
@@ -976,6 +976,11 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
976
976
  `AgentRun` and `AgentProgress`. The sole file permitted to `import type` from `ai`.
977
977
  - **`agent_progress`** — `AgentProgress`: `AgentRun`'s compact, floating, expandable form (same
978
978
  `parts` prop) — a composer's working state.
979
+ - **`follow_scroll`** — `FollowScroll`: a scroll container that FOLLOWS its growing content —
980
+ pinned to the bottom as children stream in; scrolling up unpins (read history), scrolling back
981
+ re-pins. Wrap a streaming `AgentRun` with it in any bounded container (a dialog, a drawer,
982
+ a panel — put the height bound on `style`); `AgentProgress`'s expanded panel uses it. Chat
983
+ doesn't need it (an inverted message list follows by construction).
979
984
  - **`confidence`** — `Confidence` + `ConfidenceLevel` + `levelFromScore`: calibrated
980
985
  high/med/low; localized via the provider.
981
986
  - **`change_review`** — the COMPOUND review family — frame: `ChangeReview` provider/stack ·
@@ -995,7 +1000,8 @@ source (`src/<module>.tsx`/`.ts`) is the API reference.
995
1000
  `{ value, label, description }` — the description is MANDATORY (every answer explains itself).
996
1001
  `allowCustom` adds an always-visible borderless multiline custom-answer field below the options.
997
1002
  - **`clarify_wizard`** — `ClarifyWizard` + `ClarifyWizardQuestion`/`ClarifyWizardAnswer`: a SEQUENCE
998
- of `Clarify` questions with Back/Next/Cancel/Submit + a `n / total` position indicator; advance
1003
+ of `Clarify` questions with Back/Next/Cancel/Submit + a `n / total` position indicator (omitted
1004
+ for a single question); advance
999
1005
  only when the current is answered, `onSubmit` returns one `{ value, custom }` per question.
1000
1006
  Navigation chrome is locale-wired (the `clarify` locale slice).
1001
1007
  - **`choice_list`** — `ChoiceList` + `ChoiceOption`: selectable answer options as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lotics/ui",
3
- "version": "14.0.0",
3
+ "version": "14.1.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  "./vite": {
@@ -80,6 +80,7 @@
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
+ "./follow_scroll": "./src/follow_scroll.tsx",
83
84
  "./markdown": {
84
85
  "react-native": "./src/markdown.tsx",
85
86
  "default": "./src/markdown.web.tsx"
@@ -1,8 +1,9 @@
1
1
  import { useState } from "react";
2
- import { ScrollView, StyleSheet, View } from "react-native";
2
+ import { StyleSheet, View } from "react-native";
3
3
  import { colors } from "./colors";
4
4
  import { Text } from "./text";
5
5
  import { WaveAvatar } from "./wave_avatar";
6
+ import { FollowScroll } from "./follow_scroll";
6
7
  import { PressableHighlight } from "./pressable_highlight";
7
8
  import { AgentRun, resolveToolMeta } from "./agent_run";
8
9
  import { toSegments, lastRunningStep, type AgentUIPart } from "./agent_transform";
@@ -46,9 +47,11 @@ export function AgentProgress(props: AgentProgressProps) {
46
47
  <View style={{ gap: 8 }}>
47
48
  {expanded ? (
48
49
  <View style={styles.panel}>
49
- <ScrollView style={{ maxHeight: 260 }} contentContainerStyle={{ padding: 16 }}>
50
+ {/* FollowScroll keeps the newest step in view as the run streams —
51
+ a plain ScrollView would let new content grow below the fold. */}
52
+ <FollowScroll style={{ maxHeight: 260 }} contentContainerStyle={{ padding: 16 }}>
50
53
  <AgentRun parts={parts} state={state} error={error} labelForTool={labelForTool} stepsLabel={stepsLabel} />
51
- </ScrollView>
54
+ </FollowScroll>
52
55
  </View>
53
56
  ) : null}
54
57
 
@@ -64,10 +64,11 @@ export function ClarifyWizard(props: ClarifyWizardProps) {
64
64
 
65
65
  return (
66
66
  <View style={{ gap: 16 }}>
67
- {/* The step position is an eyebrow above the question (no progress bar); the
68
- footer aligns to the same 8px inset as the question and answers. Keyed by
69
- index so each step's custom-answer draft is its own (no bleed across steps). */}
70
- <Clarify key={index} eyebrow={`${index + 1} / ${total}`} question={q.question} options={q.answers} answer={current} onAnswer={setAnswer} allowCustom={q.allowCustom} />
67
+ {/* The step position is an eyebrow above the question (no progress bar — and
68
+ none at all for a single question); the footer aligns to the same 8px inset
69
+ as the question and answers. Keyed by index so each step's custom-answer
70
+ draft is its own (no bleed across steps). */}
71
+ <Clarify key={index} eyebrow={total > 1 ? `${index + 1} / ${total}` : undefined} question={q.question} options={q.answers} answer={current} onAnswer={setAnswer} allowCustom={q.allowCustom} />
71
72
  <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8, paddingHorizontal: 8 }}>
72
73
  <Button title={labels.cancel} color="muted" onPress={onCancel} />
73
74
  <View style={{ flexDirection: "row", gap: 8 }}>
@@ -0,0 +1,58 @@
1
+ import { useRef, type ReactNode } from "react";
2
+ import { ScrollView, StyleProp, ViewStyle, type NativeScrollEvent, type NativeSyntheticEvent } from "react-native";
3
+
4
+ export interface FollowScrollProps {
5
+ children: ReactNode;
6
+ /** The frame — put the height bound here (`maxHeight` / `height` / `flex`);
7
+ * without one the container never overflows and there is nothing to follow. */
8
+ style?: StyleProp<ViewStyle>;
9
+ contentContainerStyle?: StyleProp<ViewStyle>;
10
+ }
11
+
12
+ // "At the bottom" tolerance: within this many px of the end still counts as
13
+ // pinned, so sub-pixel scroll positions and streaming-growth rounding never
14
+ // silently unpin the follow.
15
+ const PIN_THRESHOLD = 40;
16
+
17
+ /**
18
+ * A scroll container that FOLLOWS its growing content — pinned to the bottom, it
19
+ * keeps the newest content in view as children stream in (an `AgentRun` in a
20
+ * dialog, a log feed). Scrolling up UNPINS it so the human can read history while
21
+ * the content keeps growing; scrolling back to the bottom re-pins. Chat doesn't
22
+ * need this (its message list is inverted, which follows by construction) — this
23
+ * is for the plain top-down containers everywhere else: dialogs, drawers, panels.
24
+ */
25
+ export function FollowScroll(props: FollowScrollProps) {
26
+ const { children, style, contentContainerStyle } = props;
27
+ const scrollRef = useRef<ScrollView>(null);
28
+ // Pinned state lives in a ref — it changes on every scroll tick and must never
29
+ // re-render the (streaming, already-busy) subtree.
30
+ const pinnedRef = useRef(true);
31
+
32
+ const followIfPinned = () => {
33
+ if (pinnedRef.current) scrollRef.current?.scrollToEnd({ animated: false });
34
+ };
35
+
36
+ const onScroll = (e: NativeSyntheticEvent<NativeScrollEvent>) => {
37
+ const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent;
38
+ const distanceFromBottom = contentSize.height - contentOffset.y - layoutMeasurement.height;
39
+ pinnedRef.current = distanceFromBottom < PIN_THRESHOLD;
40
+ };
41
+
42
+ return (
43
+ <ScrollView
44
+ ref={scrollRef}
45
+ style={style}
46
+ contentContainerStyle={contentContainerStyle}
47
+ // Content grew (a new part streamed in) or the frame resized (dialog
48
+ // re-measured) — both move the bottom edge, so both re-follow when pinned.
49
+ onContentSizeChange={followIfPinned}
50
+ onLayout={followIfPinned}
51
+ onScroll={onScroll}
52
+ scrollEventThrottle={16}
53
+ nestedScrollEnabled
54
+ >
55
+ {children}
56
+ </ScrollView>
57
+ );
58
+ }