@lotics/ui 13.9.0 → 14.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/ai_patterns.md +57 -37
- package/docs/catalog.md +27 -9
- package/examples/tpl_dieline.tsx +21 -18
- package/examples/tpl_documents.tsx +19 -12
- package/examples/tpl_lookup.tsx +18 -13
- package/examples/tpl_record.tsx +21 -16
- package/examples/tpl_task_board.tsx +13 -8
- package/package.json +7 -2
- package/src/agent_progress.tsx +11 -12
- package/src/agent_run.tsx +82 -121
- package/src/agent_transform.ts +102 -0
- package/src/choice_list.tsx +103 -14
- package/src/clarify.tsx +31 -15
- package/src/clarify_wizard.tsx +84 -0
- package/src/json_panel.tsx +25 -13
- package/src/locale.tsx +4 -0
- package/src/primitives_purity.test.ts +15 -1
- package/src/table.tsx +5 -1
package/src/agent_run.tsx
CHANGED
|
@@ -5,59 +5,31 @@ import { Text } from "./text";
|
|
|
5
5
|
import { Icon, type IconName } from "./icon";
|
|
6
6
|
import { Markdown } from "./markdown";
|
|
7
7
|
import { JsonPanel, stringifyData } from "./json_panel";
|
|
8
|
-
import { Peek } from "./peek";
|
|
9
8
|
import { PressableHighlight } from "./pressable_highlight";
|
|
10
9
|
import { Marker, type StepStatus } from "./stepper";
|
|
11
10
|
import { AnimationFadeIn } from "./animation_fade_in";
|
|
12
11
|
import { CONTROL_HEIGHT, CONTROL_RADIUS } from "./control_surface";
|
|
13
12
|
import { useLoticsLocale, type LoticsLocale } from "./locale";
|
|
14
13
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
/** A `tool` step carries the RAW tool name (`update_records`) — the feed maps
|
|
20
|
-
* it to a human label via the default map / `labelForTool`. A `step` carries
|
|
21
|
-
* an already-human label. */
|
|
22
|
-
label: string;
|
|
23
|
-
status: AgentStepStatus;
|
|
24
|
-
kind?: "step" | "tool";
|
|
25
|
-
/** The tool call's input (arguments) and result. When present (and no explicit
|
|
26
|
-
* `peek`), the feed reveals them ON DEMAND — a press-to-open peek with an Input /
|
|
27
|
-
* Output panel — instead of cluttering the row; the row itself shows only the
|
|
28
|
-
* label + state. */
|
|
29
|
-
input?: unknown;
|
|
30
|
-
output?: unknown;
|
|
31
|
-
/** The tool failure message (shown in the peek when `status` is `"error"`). */
|
|
32
|
-
errorText?: string;
|
|
33
|
-
/** A note under the label — a short, human summary (a count, a result summary),
|
|
34
|
-
* never invented prose. Muted. For raw tool I/O prefer `input`/`output`. */
|
|
35
|
-
detail?: string;
|
|
36
|
-
/** Custom content for the press-to-open peek. Overrides the auto-built Input /
|
|
37
|
-
* Output panel — pass this to render the reveal yourself. */
|
|
38
|
-
peek?: ReactNode;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* The ordered transcript of a run, in the order it happened: the agent's answer
|
|
43
|
-
* prose (`text`), its thinking (`reasoning`, shown collapsed / revealed on demand),
|
|
44
|
-
* and the tools it runs between prose (`step`). A real run is a timeline — text, a
|
|
45
|
-
* burst of tool calls, more text — so the feed is an ordered array, NOT a text
|
|
46
|
-
* block over a flat step list. Consecutive `step` items collapse into ONE activity
|
|
47
|
-
* group; a `step`'s `input`/`output` reveal in a peek on press.
|
|
48
|
-
*/
|
|
49
|
-
export type AgentRunItem =
|
|
50
|
-
| { type: "text"; id: string; text: string }
|
|
51
|
-
| { type: "reasoning"; id: string; text: string }
|
|
52
|
-
| ({ type: "step" } & AgentRunStep);
|
|
14
|
+
// The render model comes STRAIGHT from ai-sdk `UIMessage.parts` — no bespoke
|
|
15
|
+
// transcript type. `agent_transform` is the one place that folds parts into the
|
|
16
|
+
// timeline; this file is the renderer.
|
|
17
|
+
import { toSegments, anyRunning, type AgentUIPart, type AgentStep } from "./agent_transform";
|
|
53
18
|
|
|
54
19
|
export interface AgentRunProps {
|
|
55
|
-
/** The
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
* to `streaming` while any
|
|
20
|
+
/** The agent message's ai-sdk `parts` — the ordered transcript, rendered in
|
|
21
|
+
* place. Chat passes `message.parts`; an app passes `useAgentRun().parts`. */
|
|
22
|
+
parts: readonly AgentUIPart[];
|
|
23
|
+
/** Whole-run state. `streaming` keeps the tail activity group pulsing;
|
|
24
|
+
* `done`/`error` settle every group. Defaults to `streaming` while any tool
|
|
25
|
+
* step is running. */
|
|
60
26
|
state?: "streaming" | "done" | "error";
|
|
27
|
+
/** A run-level BREAKING error — the message that terminated the run (a stream
|
|
28
|
+
* error, an API failure, a hit quota). It lives OUTSIDE `parts` (chat stores it
|
|
29
|
+
* in the message's `errors`; `useAgentRun().error` carries it), so pass it here;
|
|
30
|
+
* it renders as a terminal danger row under the transcript. A per-tool failure
|
|
31
|
+
* is different — it rides in that tool's own `output-error` part. */
|
|
32
|
+
error?: string;
|
|
61
33
|
/** Localize / override a TOOL step's display label by its raw tool name
|
|
62
34
|
* (`update_records` → "Đang cập nhật dữ liệu"). Return `undefined` to fall
|
|
63
35
|
* back to the built-in label. */
|
|
@@ -107,36 +79,9 @@ export function resolveToolMeta(
|
|
|
107
79
|
};
|
|
108
80
|
}
|
|
109
81
|
|
|
110
|
-
/** Resolve a step's display label
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return s.kind === "tool" ? resolveToolMeta(s.label, labelForTool).label : s.label;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// A run is a timeline of segments: prose, thinking, then a group of consecutive
|
|
117
|
-
// tool calls, then more prose. Folding the flat item list into segments is what
|
|
118
|
-
// lets the feed interleave instead of forcing all text to the top.
|
|
119
|
-
type Segment =
|
|
120
|
-
| { kind: "text"; id: string; text: string }
|
|
121
|
-
| { kind: "reasoning"; id: string; text: string }
|
|
122
|
-
| { kind: "group"; id: string; steps: AgentRunStep[] };
|
|
123
|
-
|
|
124
|
-
function toSegments(items: AgentRunItem[]): Segment[] {
|
|
125
|
-
const segments: Segment[] = [];
|
|
126
|
-
for (const item of items) {
|
|
127
|
-
if (item.type === "text" || item.type === "reasoning") {
|
|
128
|
-
segments.push({ kind: item.type, id: item.id, text: item.text });
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
const { type: _t, ...step } = item;
|
|
132
|
-
const tail = segments[segments.length - 1];
|
|
133
|
-
if (tail && tail.kind === "group") {
|
|
134
|
-
segments[segments.length - 1] = { ...tail, steps: [...tail.steps, step] };
|
|
135
|
-
} else {
|
|
136
|
-
segments.push({ kind: "group", id: item.id, steps: [step] });
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
return segments;
|
|
82
|
+
/** Resolve a tool step's display label from its raw tool name. */
|
|
83
|
+
function stepLabel(s: AgentStep, labelForTool?: (toolName: string) => string | undefined): string {
|
|
84
|
+
return resolveToolMeta(s.toolName, labelForTool).label;
|
|
140
85
|
}
|
|
141
86
|
|
|
142
87
|
const INK = colors.zinc[700];
|
|
@@ -147,15 +92,14 @@ const INK = colors.zinc[700];
|
|
|
147
92
|
* tail group is a SINGLE pulsing row whose label swaps in place as each call
|
|
148
93
|
* fires (not a growing stack of dots); once prose resumes the group settles into
|
|
149
94
|
* one row — "{final action} · {n} steps" — that EXPANDS on press to the steps in
|
|
150
|
-
* between. Text segments render as prose
|
|
151
|
-
*
|
|
95
|
+
* between. Text segments render as prose. The run always ends on the agent's text.
|
|
96
|
+
* Pair with `Composer` + `ChangeReview`.
|
|
152
97
|
*/
|
|
153
98
|
export function AgentRun(props: AgentRunProps) {
|
|
154
|
-
const {
|
|
155
|
-
const
|
|
156
|
-
const state = props.state ?? (anyRunning ? "streaming" : "done");
|
|
99
|
+
const { parts, labelForTool, stepsLabel = (n) => `${n} steps`, accessibilityLabel } = props;
|
|
100
|
+
const segments = toSegments(parts);
|
|
101
|
+
const state = props.state ?? (anyRunning(segments) ? "streaming" : "done");
|
|
157
102
|
const streaming = state === "streaming";
|
|
158
|
-
const segments = toSegments(items);
|
|
159
103
|
const lastIndex = segments.length - 1;
|
|
160
104
|
|
|
161
105
|
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
|
|
@@ -167,7 +111,7 @@ export function AgentRun(props: AgentRunProps) {
|
|
|
167
111
|
if (seg.kind === "text") {
|
|
168
112
|
return (
|
|
169
113
|
<View key={seg.id} style={styles.narration}>
|
|
170
|
-
<Markdown>{
|
|
114
|
+
<Markdown>{seg.text}</Markdown>
|
|
171
115
|
</View>
|
|
172
116
|
);
|
|
173
117
|
}
|
|
@@ -186,6 +130,20 @@ export function AgentRun(props: AgentRunProps) {
|
|
|
186
130
|
/>
|
|
187
131
|
);
|
|
188
132
|
})}
|
|
133
|
+
{/* 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. */}
|
|
135
|
+
{props.error ? (
|
|
136
|
+
<View style={styles.row}>
|
|
137
|
+
<View style={styles.dotCol}>
|
|
138
|
+
<Icon name="circle-alert" size={17} color={colors.red[500]} />
|
|
139
|
+
</View>
|
|
140
|
+
<View style={styles.rowBody}>
|
|
141
|
+
<Text size="sm" color="danger">
|
|
142
|
+
{props.error}
|
|
143
|
+
</Text>
|
|
144
|
+
</View>
|
|
145
|
+
</View>
|
|
146
|
+
) : null}
|
|
189
147
|
</View>
|
|
190
148
|
);
|
|
191
149
|
}
|
|
@@ -213,7 +171,7 @@ function ActivityRow(props: {
|
|
|
213
171
|
);
|
|
214
172
|
if (onPress) {
|
|
215
173
|
return (
|
|
216
|
-
<PressableHighlight focusRing onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel} style={styles.row}>
|
|
174
|
+
<PressableHighlight focusRing userSelect="none" onPress={onPress} accessibilityRole="button" accessibilityLabel={accessibilityLabel} style={styles.row}>
|
|
217
175
|
{inner}
|
|
218
176
|
</PressableHighlight>
|
|
219
177
|
);
|
|
@@ -221,25 +179,14 @@ function ActivityRow(props: {
|
|
|
221
179
|
return <View style={styles.row}>{inner}</View>;
|
|
222
180
|
}
|
|
223
181
|
|
|
224
|
-
function StepBody({ label
|
|
225
|
-
return
|
|
226
|
-
<View style={{ gap: 1 }}>
|
|
227
|
-
<Text size="sm">{label}</Text>
|
|
228
|
-
{detail ? (
|
|
229
|
-
<Text size="xs" color={status === "error" ? "danger" : "muted"}>
|
|
230
|
-
{detail}
|
|
231
|
-
</Text>
|
|
232
|
-
) : null}
|
|
233
|
-
</View>
|
|
234
|
-
);
|
|
182
|
+
function StepBody({ label }: { label: string }) {
|
|
183
|
+
return <Text size="sm">{label}</Text>;
|
|
235
184
|
}
|
|
236
185
|
|
|
237
|
-
// The
|
|
238
|
-
//
|
|
239
|
-
//
|
|
240
|
-
|
|
241
|
-
function stepPeek(s: AgentRunStep, labels: LoticsLocale["agentRun"]): ReactNode {
|
|
242
|
-
if (s.peek) return s.peek;
|
|
186
|
+
// The on-demand reveal for a tool step: Input / Output / Error panels auto-built
|
|
187
|
+
// from the carried I/O; nothing if there's none. Raw I/O stays OUT of the row — only
|
|
188
|
+
// shown here, on demand. Panel titles come from the locale.
|
|
189
|
+
function stepDetail(s: AgentStep, labels: LoticsLocale["agentRun"]): ReactNode {
|
|
243
190
|
const hasInput = s.input !== undefined;
|
|
244
191
|
const hasOutput = s.output !== undefined;
|
|
245
192
|
if (!hasInput && !hasOutput && !s.errorText) return null;
|
|
@@ -255,18 +202,28 @@ function stepPeek(s: AgentRunStep, labels: LoticsLocale["agentRun"]): ReactNode
|
|
|
255
202
|
);
|
|
256
203
|
}
|
|
257
204
|
|
|
258
|
-
// A step
|
|
259
|
-
//
|
|
260
|
-
|
|
205
|
+
// A settled step row. When the step carries I/O (or an error) it EXPANDS IN PLACE on
|
|
206
|
+
// press — the panels roll out under the row (same disclosure pattern as "Thinking"),
|
|
207
|
+
// never a popover — so an errored call reads like any other step until you open it.
|
|
208
|
+
function StepRow({ s, label }: { s: AgentStep; label: string }) {
|
|
261
209
|
const locale = useLoticsLocale();
|
|
262
|
-
const
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
210
|
+
const [open, setOpen] = useState(false);
|
|
211
|
+
const detail = stepDetail(s, locale.agentRun);
|
|
212
|
+
const marker: StepStatus = s.status === "error" ? "warning" : "done";
|
|
213
|
+
if (!detail) {
|
|
214
|
+
return (
|
|
215
|
+
<ActivityRow markerStatus={marker}>
|
|
216
|
+
<StepBody label={label} />
|
|
217
|
+
</ActivityRow>
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
return (
|
|
221
|
+
<View>
|
|
222
|
+
<ActivityRow markerStatus={marker} onPress={() => setOpen((o) => !o)} accessibilityLabel={label} trailing={chevron(open ? "up" : "down")}>
|
|
223
|
+
<StepBody label={label} />
|
|
224
|
+
</ActivityRow>
|
|
225
|
+
{open ? <View style={styles.rowDetail}>{detail}</View> : null}
|
|
226
|
+
</View>
|
|
270
227
|
);
|
|
271
228
|
}
|
|
272
229
|
|
|
@@ -277,7 +234,7 @@ function ReasoningDisclosure(props: { text: string; streaming?: boolean; expande
|
|
|
277
234
|
const locale = useLoticsLocale();
|
|
278
235
|
return (
|
|
279
236
|
<View style={styles.group}>
|
|
280
|
-
<PressableHighlight focusRing onPress={onToggle} accessibilityRole="button" accessibilityLabel={locale.agentRun.thinking} style={styles.row}>
|
|
237
|
+
<PressableHighlight focusRing userSelect="none" onPress={onToggle} accessibilityRole="button" accessibilityLabel={locale.agentRun.thinking} style={styles.row}>
|
|
281
238
|
<View style={styles.dotCol}>
|
|
282
239
|
<Marker status={streaming ? "current" : "done"} color={colors.zinc[400]} live={!!streaming} />
|
|
283
240
|
</View>
|
|
@@ -313,7 +270,7 @@ function chevron(dir: "down" | "up") {
|
|
|
313
270
|
}
|
|
314
271
|
|
|
315
272
|
function ToolGroup(props: {
|
|
316
|
-
steps:
|
|
273
|
+
steps: AgentStep[];
|
|
317
274
|
active: boolean;
|
|
318
275
|
expanded: boolean;
|
|
319
276
|
onToggle: () => void;
|
|
@@ -321,7 +278,7 @@ function ToolGroup(props: {
|
|
|
321
278
|
stepsLabel: (n: number) => string;
|
|
322
279
|
}) {
|
|
323
280
|
const { steps, active, expanded, onToggle, labelForTool, stepsLabel } = props;
|
|
324
|
-
const resolve = (s:
|
|
281
|
+
const resolve = (s: AgentStep) => stepLabel(s, labelForTool);
|
|
325
282
|
|
|
326
283
|
// ACTIVE — one pulsing row whose label swaps in place as each call fires (the
|
|
327
284
|
// label is keyed by the current step's id, so a new call rises + fades into the
|
|
@@ -332,7 +289,7 @@ function ToolGroup(props: {
|
|
|
332
289
|
<View style={styles.group}>
|
|
333
290
|
<ActivityRow markerStatus="current" live>
|
|
334
291
|
<AnimationFadeIn key={current.id} translateY={4}>
|
|
335
|
-
<StepBody label={resolve(current)}
|
|
292
|
+
<StepBody label={resolve(current)} />
|
|
336
293
|
</AnimationFadeIn>
|
|
337
294
|
</ActivityRow>
|
|
338
295
|
</View>
|
|
@@ -343,13 +300,11 @@ function ToolGroup(props: {
|
|
|
343
300
|
const final = steps[steps.length - 1];
|
|
344
301
|
const expandable = steps.length > 1;
|
|
345
302
|
|
|
346
|
-
// SETTLED, single call —
|
|
303
|
+
// SETTLED, single call — one done/warning row (its I/O expands in place on press).
|
|
347
304
|
if (!expandable) {
|
|
348
305
|
return (
|
|
349
306
|
<View style={styles.group}>
|
|
350
|
-
<
|
|
351
|
-
<StepContent s={final} label={resolve(final)} />
|
|
352
|
-
</ActivityRow>
|
|
307
|
+
<StepRow s={final} label={resolve(final)} />
|
|
353
308
|
</View>
|
|
354
309
|
);
|
|
355
310
|
}
|
|
@@ -372,9 +327,7 @@ function ToolGroup(props: {
|
|
|
372
327
|
{expanded
|
|
373
328
|
? steps.map((s) => (
|
|
374
329
|
<AnimationFadeIn key={s.id} translateY={4}>
|
|
375
|
-
<
|
|
376
|
-
<StepContent s={s} label={resolve(s)} />
|
|
377
|
-
</ActivityRow>
|
|
330
|
+
<StepRow s={s} label={resolve(s)} />
|
|
378
331
|
</AnimationFadeIn>
|
|
379
332
|
))
|
|
380
333
|
: null}
|
|
@@ -394,9 +347,17 @@ const styles = StyleSheet.create({
|
|
|
394
347
|
gap: 10,
|
|
395
348
|
minHeight: CONTROL_HEIGHT,
|
|
396
349
|
borderRadius: CONTROL_RADIUS,
|
|
350
|
+
// The hover/press highlight BLEEDS 8px past the content on each side (negative
|
|
351
|
+
// margin absorbed by equal padding, the Peek pattern), so its rounded edges
|
|
352
|
+
// never pinch against the leading dot or the trailing chevron. Content stays
|
|
353
|
+
// flush-aligned (net zero), so the run's left edge still meets the consumer's gutter.
|
|
354
|
+
marginHorizontal: -8,
|
|
355
|
+
paddingHorizontal: 8,
|
|
397
356
|
},
|
|
398
357
|
dotCol: { width: 18, alignItems: "center" },
|
|
399
358
|
rowBody: { flex: 1 },
|
|
400
359
|
// Reasoning body aligns under the "Thinking" label (past the dot column + gap).
|
|
401
360
|
reasoning: { paddingLeft: 28, paddingBottom: 4 },
|
|
361
|
+
// A step's expanded I/O panels — same left edge as the reasoning body.
|
|
362
|
+
rowDetail: { paddingLeft: 28, paddingBottom: 6, paddingTop: 2 },
|
|
402
363
|
});
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { UIMessagePart, UIDataTypes, UITools } from "ai";
|
|
2
|
+
|
|
3
|
+
// `@lotics/ui` renders an agent run straight from the ai-sdk `UIMessage.parts` — the
|
|
4
|
+
// canonical, versioned wire shape BOTH the chat and app agents already emit. There
|
|
5
|
+
// is no bespoke transcript type: this module is the one place that folds parts into
|
|
6
|
+
// the render timeline, shared by `AgentRun` and `AgentProgress`. Consumers pass their
|
|
7
|
+
// message's `parts` (chat's static `tool-<name>` parts and the app SDK's
|
|
8
|
+
// `dynamic-tool` parts are both handled).
|
|
9
|
+
|
|
10
|
+
/** An ai-sdk message part, tool-set-agnostic (we only read `type` + a few fields). */
|
|
11
|
+
export type AgentUIPart = UIMessagePart<UIDataTypes, UITools>;
|
|
12
|
+
|
|
13
|
+
/** A tool step's settle state, collapsed from ai's 7-state tool machine. */
|
|
14
|
+
export type AgentStepStatus = "running" | "done" | "error";
|
|
15
|
+
|
|
16
|
+
/** One tool call, reduced to what the feed shows. `toolName` is the RAW name — the
|
|
17
|
+
* renderer maps it to a human label + icon (`resolveToolMeta`). */
|
|
18
|
+
export interface AgentStep {
|
|
19
|
+
id: string;
|
|
20
|
+
toolName: string;
|
|
21
|
+
status: AgentStepStatus;
|
|
22
|
+
/** Revealed ON DEMAND (a press-to-open peek), never inline. */
|
|
23
|
+
input?: unknown;
|
|
24
|
+
output?: unknown;
|
|
25
|
+
errorText?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** The render timeline: prose, thinking, and groups of consecutive tool calls, in
|
|
29
|
+
* the order they streamed. */
|
|
30
|
+
export type AgentSegment =
|
|
31
|
+
| { kind: "text"; id: string; text: string }
|
|
32
|
+
| { kind: "reasoning"; id: string; text: string }
|
|
33
|
+
| { kind: "group"; id: string; steps: AgentStep[] };
|
|
34
|
+
|
|
35
|
+
// A tool part — static (`tool-<name>`) or `dynamic-tool` — is exactly the part union
|
|
36
|
+
// that carries `toolCallId`, so extracting on that field is a sound, cast-free guard.
|
|
37
|
+
function isToolPart(part: AgentUIPart): part is Extract<AgentUIPart, { toolCallId: string }> {
|
|
38
|
+
return part.type === "dynamic-tool" || part.type.startsWith("tool-");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ai's tool state machine → the feed's 3 states. Approval-pending/responded read as
|
|
42
|
+
// "running" (a read-only feed has no approval affordance); denied reads as an error.
|
|
43
|
+
function toStatus(state: string): AgentStepStatus {
|
|
44
|
+
if (state === "output-available") return "done";
|
|
45
|
+
if (state === "output-error" || state === "output-denied") return "error";
|
|
46
|
+
return "running";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function toStep(part: Extract<AgentUIPart, { toolCallId: string }>): AgentStep {
|
|
50
|
+
return {
|
|
51
|
+
id: part.toolCallId,
|
|
52
|
+
toolName: part.type === "dynamic-tool" ? part.toolName : part.type.slice("tool-".length),
|
|
53
|
+
status: toStatus(part.state),
|
|
54
|
+
input: part.input,
|
|
55
|
+
output: part.state === "output-available" ? part.output : undefined,
|
|
56
|
+
errorText: part.state === "output-error" ? part.errorText : undefined,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Fold a message's parts into the render timeline. Consecutive tool parts collapse
|
|
62
|
+
* into ONE group (the feed shows a burst as a single expandable row). Prose parts
|
|
63
|
+
* carry no id in ai's model, so a stable index-based one is synthesized. Part kinds
|
|
64
|
+
* with no feed representation (`step-start`, `file`, `source-*`, `data-*`, `custom`)
|
|
65
|
+
* are skipped — the same kinds the chat's own renderer ignores.
|
|
66
|
+
*/
|
|
67
|
+
export function toSegments(parts: readonly AgentUIPart[]): AgentSegment[] {
|
|
68
|
+
const segments: AgentSegment[] = [];
|
|
69
|
+
parts.forEach((part, i) => {
|
|
70
|
+
if (part.type === "text") {
|
|
71
|
+
if (part.text) segments.push({ kind: "text", id: `text-${i}`, text: part.text });
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (part.type === "reasoning") {
|
|
75
|
+
if (part.text) segments.push({ kind: "reasoning", id: `reasoning-${i}`, text: part.text });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!isToolPart(part)) return;
|
|
79
|
+
const step = toStep(part);
|
|
80
|
+
const tail = segments[segments.length - 1];
|
|
81
|
+
if (tail?.kind === "group") tail.steps.push(step);
|
|
82
|
+
else segments.push({ kind: "group", id: step.id, steps: [step] });
|
|
83
|
+
});
|
|
84
|
+
return segments;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Whether any tool step is still running — the default "is the run streaming" signal. */
|
|
88
|
+
export function anyRunning(segments: readonly AgentSegment[]): boolean {
|
|
89
|
+
return segments.some((s) => s.kind === "group" && s.steps.some((st) => st.status === "running"));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The last still-running tool step, if any — for a compact "currently doing X". */
|
|
93
|
+
export function lastRunningStep(segments: readonly AgentSegment[]): AgentStep | undefined {
|
|
94
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
95
|
+
const seg = segments[i];
|
|
96
|
+
if (seg.kind !== "group") continue;
|
|
97
|
+
for (let j = seg.steps.length - 1; j >= 0; j--) {
|
|
98
|
+
if (seg.steps[j].status === "running") return seg.steps[j];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return undefined;
|
|
102
|
+
}
|
package/src/choice_list.tsx
CHANGED
|
@@ -1,10 +1,22 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Pressable, TextInput, View } from "react-native";
|
|
2
3
|
import { colors } from "./colors";
|
|
3
4
|
import { Text } from "./text";
|
|
4
5
|
import { Icon } from "./icon";
|
|
5
6
|
import { Divider } from "./divider";
|
|
6
|
-
import { useFocusRing } from "./use_focus_ring";
|
|
7
|
-
import {
|
|
7
|
+
import { composeHandler, useFocusRing } from "./use_focus_ring";
|
|
8
|
+
import { useHover } from "./use_hover";
|
|
9
|
+
import { CONTROL_RADIUS, FOCUS_RING } from "./control_surface";
|
|
10
|
+
import { useAutoGrowHeight } from "./use_auto_grow_height";
|
|
11
|
+
import { fontFamilyMedium, getInputTextStyle } from "./text_utils";
|
|
12
|
+
import { useLoticsLocale } from "./locale";
|
|
13
|
+
|
|
14
|
+
// A typed custom answer must read exactly like a picked option's LABEL (`Text
|
|
15
|
+
// size="sm" weight="medium"`). Its SIZE stays on the input contract (getInputTextStyle:
|
|
16
|
+
// 14 on desktop = the sm label, 16 on mobile to defeat iOS-Safari focus-zoom — and the
|
|
17
|
+
// size useAutoGrowHeight is tuned to); this override only aligns the family, tracking,
|
|
18
|
+
// and colour to the label, which is where an unstyled input actually diverges from it.
|
|
19
|
+
const ANSWER_LABEL_FONT = { fontFamily: fontFamilyMedium, letterSpacing: -0.4, color: colors.zinc[900] } as const;
|
|
8
20
|
|
|
9
21
|
export interface ChoiceOption {
|
|
10
22
|
label: string;
|
|
@@ -15,25 +27,33 @@ export interface ChoiceOption {
|
|
|
15
27
|
|
|
16
28
|
export interface ChoiceListProps {
|
|
17
29
|
options: ChoiceOption[];
|
|
18
|
-
/** The chosen value
|
|
30
|
+
/** The chosen value — an option's `value`, or (with `allowCustom`) the free
|
|
31
|
+
* TEXT of an "Other" answer. `undefined` = nothing chosen. */
|
|
19
32
|
value?: string;
|
|
20
33
|
onSelect: (value: string) => void;
|
|
34
|
+
/** Show an always-visible inline free-text field at the bottom for a custom
|
|
35
|
+
* answer — typing it IS the selection. Its text IS the value (any `value` not
|
|
36
|
+
* matching an option reads as the custom answer, so the caller distinguishes a
|
|
37
|
+
* custom answer by "not in `options`"). */
|
|
38
|
+
allowCustom?: boolean;
|
|
21
39
|
}
|
|
22
40
|
|
|
23
41
|
/** One selectable option — a role-less-free `Pressable` (its own accessible name
|
|
24
42
|
* + selected state), hover wash + focus ring, no border. */
|
|
25
|
-
function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selected: boolean; onSelect: (
|
|
43
|
+
function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selected: boolean; onSelect: () => void }) {
|
|
26
44
|
const { focusVisible, focusProps } = useFocusRing();
|
|
27
45
|
return (
|
|
28
46
|
<Pressable
|
|
29
47
|
accessibilityRole="button"
|
|
30
48
|
accessibilityLabel={option.label}
|
|
31
49
|
accessibilityState={{ selected }}
|
|
32
|
-
onPress={
|
|
50
|
+
onPress={onSelect}
|
|
33
51
|
{...focusProps}
|
|
34
52
|
style={({ hovered, pressed }) => [
|
|
35
|
-
{ flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 14, paddingHorizontal:
|
|
36
|
-
|
|
53
|
+
{ flexDirection: "row", alignItems: "center", gap: 12, paddingVertical: 14, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS },
|
|
54
|
+
// Selected reads as a persistent tint (zinc-100); an unselected row only
|
|
55
|
+
// washes on hover (zinc-50). The check below reserves its slot always.
|
|
56
|
+
selected ? { backgroundColor: colors.zinc[100] } : hovered || pressed ? { backgroundColor: colors.zinc[50] } : null,
|
|
37
57
|
focusVisible ? { boxShadow: FOCUS_RING } : null,
|
|
38
58
|
]}
|
|
39
59
|
>
|
|
@@ -47,28 +67,97 @@ function ChoiceRow({ option, selected, onSelect }: { option: ChoiceOption; selec
|
|
|
47
67
|
</Text>
|
|
48
68
|
) : null}
|
|
49
69
|
</View>
|
|
50
|
-
|
|
70
|
+
<View style={{ width: 17, alignItems: "center" }}>
|
|
71
|
+
{selected ? <Icon name="check" size={17} color={colors.zinc[900]} /> : null}
|
|
72
|
+
</View>
|
|
51
73
|
</Pressable>
|
|
52
74
|
);
|
|
53
75
|
}
|
|
54
76
|
|
|
77
|
+
/** The custom-answer row — the SAME box as a `ChoiceRow` (padding · radius · hover
|
|
78
|
+
* wash · focus ring), but holding an always-editable, auto-growing multiline input
|
|
79
|
+
* instead of a pressable label. Typing it IS the selection; focusing it re-selects a
|
|
80
|
+
* parked draft (`onFocus`). */
|
|
81
|
+
function CustomAnswerRow({ value, selected, onChangeText, onFocus, placeholder }: { value: string; selected: boolean; onChangeText: (text: string) => void; onFocus: () => void; placeholder: string }) {
|
|
82
|
+
const { hovered, hoverProps } = useHover();
|
|
83
|
+
const { focusVisible, focusProps } = useFocusRing({ always: true });
|
|
84
|
+
const grow = useAutoGrowHeight({ minLines: 1 });
|
|
85
|
+
return (
|
|
86
|
+
<View
|
|
87
|
+
{...(hoverProps as object)}
|
|
88
|
+
style={[
|
|
89
|
+
{ paddingVertical: 14, paddingHorizontal: 8, borderRadius: CONTROL_RADIUS },
|
|
90
|
+
// Same states as an option row: a filled custom answer reads as selected
|
|
91
|
+
// (zinc-100); otherwise it washes on hover (zinc-50).
|
|
92
|
+
selected ? { backgroundColor: colors.zinc[100] } : hovered ? { backgroundColor: colors.zinc[50] } : null,
|
|
93
|
+
focusVisible ? { boxShadow: FOCUS_RING } : null,
|
|
94
|
+
]}
|
|
95
|
+
>
|
|
96
|
+
<TextInput
|
|
97
|
+
ref={grow.inputRef}
|
|
98
|
+
multiline
|
|
99
|
+
value={value}
|
|
100
|
+
onChangeText={(text) => { onChangeText(text); grow.measure(); }}
|
|
101
|
+
onFocus={composeHandler(onFocus, focusProps.onFocus)}
|
|
102
|
+
onBlur={focusProps.onBlur}
|
|
103
|
+
onContentSizeChange={grow.onContentSizeChange}
|
|
104
|
+
scrollEnabled={grow.scrollEnabled}
|
|
105
|
+
placeholder={placeholder}
|
|
106
|
+
placeholderTextColor={colors.zinc[400]}
|
|
107
|
+
// Bare input: the row owns padding + hover + focus ring, so it strips its own
|
|
108
|
+
// padding and browser outline and just grows with the text. Input-contract size +
|
|
109
|
+
// the option-label family/tracking/colour, so a typed answer reads like a picked one.
|
|
110
|
+
style={[getInputTextStyle(), ANSWER_LABEL_FONT, { height: grow.containerHeight, padding: 0, outlineStyle: "none" as unknown as "solid" }]}
|
|
111
|
+
/>
|
|
112
|
+
</View>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
55
116
|
/**
|
|
56
117
|
* A vertical set of selectable answer options — divider-separated rows (no
|
|
57
118
|
* bordered cards), each with its own focus ring + hover wash, single-select and
|
|
58
119
|
* freely switchable (pick a different option any time). The chosen row shows a
|
|
59
|
-
* check.
|
|
60
|
-
*
|
|
120
|
+
* check. With `allowCustom`, an always-visible inline free-text field at the
|
|
121
|
+
* bottom captures a custom answer (typing it IS the selection). The agent's
|
|
122
|
+
* quick-reply surface (`Clarify` / `ClarifyWizard` use it) and any "pick one" question.
|
|
61
123
|
*/
|
|
62
124
|
export function ChoiceList(props: ChoiceListProps) {
|
|
63
|
-
const { options, value, onSelect } = props;
|
|
125
|
+
const { options, value, onSelect, allowCustom } = props;
|
|
126
|
+
const labels = useLoticsLocale().clarify;
|
|
127
|
+
// A value that matches no option IS a custom answer (its own text, or "" right
|
|
128
|
+
// after focusing an empty custom row).
|
|
129
|
+
const isCustomValue = (v: string | undefined): v is string => !!allowCustom && typeof v === "string" && !options.some((o) => o.value === v);
|
|
130
|
+
// The custom TEXT is a draft that outlives selection: picking an option deselects
|
|
131
|
+
// the custom row but must KEEP its text so the human can return to it. The single
|
|
132
|
+
// `value` can't hold both a picked option and the parked text, so the draft is
|
|
133
|
+
// local — seeded from `value` when this row mounts already custom-selected.
|
|
134
|
+
const [customDraft, setCustomDraft] = useState(() => (isCustomValue(value) ? value : ""));
|
|
135
|
+
const customSelected = isCustomValue(value);
|
|
64
136
|
return (
|
|
65
137
|
<View>
|
|
66
138
|
{options.map((o, i) => (
|
|
67
139
|
<View key={o.value}>
|
|
68
|
-
{
|
|
69
|
-
|
|
140
|
+
{/* The hairline carries vertical padding so each row's rounded hover/selected
|
|
141
|
+
fill has air on both sides of it (a flush divider would pinch the corners). */}
|
|
142
|
+
{i > 0 ? <Divider paddingVertical={6} /> : null}
|
|
143
|
+
<ChoiceRow option={o} selected={value === o.value} onSelect={() => onSelect(o.value)} />
|
|
70
144
|
</View>
|
|
71
145
|
))}
|
|
146
|
+
{allowCustom ? (
|
|
147
|
+
<View>
|
|
148
|
+
{options.length > 0 ? <Divider paddingVertical={6} /> : null}
|
|
149
|
+
{/* The custom answer is an ALWAYS-visible inline row (no press-to-reveal). Its
|
|
150
|
+
text persists (customDraft) across option switches; typing selects it, and
|
|
151
|
+
focusing a non-empty parked draft re-selects it without retyping. */}
|
|
152
|
+
<CustomAnswerRow
|
|
153
|
+
value={customDraft}
|
|
154
|
+
selected={customSelected && customDraft.length > 0}
|
|
155
|
+
onChangeText={(text) => { setCustomDraft(text); onSelect(text); }}
|
|
156
|
+
onFocus={() => { if (customDraft.length > 0 && !customSelected) onSelect(customDraft); }}
|
|
157
|
+
placeholder={labels.otherPlaceholder}
|
|
158
|
+
/>
|
|
159
|
+
</View>
|
|
160
|
+
) : null}
|
|
72
161
|
</View>
|
|
73
162
|
);
|
|
74
163
|
}
|